From 0526243cc15f90c5ab2ae72342aa2ff2251b4352 Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Wed, 30 Apr 2025 18:15:31 -0500 Subject: [PATCH 01/20] Initial WIP autocomplete v2 build-out --- assets/css/autosuggest-v2.css | 73 + .../components/AutosuggestUI.js | 104 ++ .../autosuggest-v2/components/FilterTabs.js | 43 + .../components/SuggestionItem.js | 23 + .../components/SuggestionList.js | 44 + assets/js/autosuggest-v2/index.js | 166 +++ assets/js/autosuggest-v2/src/config.js | 83 ++ assets/js/autosuggest-v2/src/context.js | 7 + .../autosuggest-v2/src/useAutosuggestInput.js | 119 ++ elasticpress.php | 4 + .../Feature/AutosuggestV2/AutosuggestV2.php | 1220 +++++++++++++++++ package.json | 2 + 12 files changed, 1888 insertions(+) create mode 100644 assets/css/autosuggest-v2.css create mode 100644 assets/js/autosuggest-v2/components/AutosuggestUI.js create mode 100644 assets/js/autosuggest-v2/components/FilterTabs.js create mode 100644 assets/js/autosuggest-v2/components/SuggestionItem.js create mode 100644 assets/js/autosuggest-v2/components/SuggestionList.js create mode 100644 assets/js/autosuggest-v2/index.js create mode 100644 assets/js/autosuggest-v2/src/config.js create mode 100644 assets/js/autosuggest-v2/src/context.js create mode 100644 assets/js/autosuggest-v2/src/useAutosuggestInput.js create mode 100644 includes/classes/Feature/AutosuggestV2/AutosuggestV2.php diff --git a/assets/css/autosuggest-v2.css b/assets/css/autosuggest-v2.css new file mode 100644 index 0000000000..5a0f7f9606 --- /dev/null +++ b/assets/css/autosuggest-v2.css @@ -0,0 +1,73 @@ +@import "./global/colors.css"; + +.ep-autosuggest-container { + 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); + display: none; + position: absolute; + + width: 100%; + z-index: 200; + + & > ul { + list-style: none; + margin: 0 !important; + + & > li { + font-family: sans-serif; + + & > a.autosuggest-link { + color: var(--ep-c-black); + cursor: pointer; + display: block; + padding: 2px 10px; + + &: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 { + background: #fff; + min-width: 500px; + padding: 20px; +} + +button.ep-autosuggest-filter { + padding: 5px 10px; +} + +.ep-autosuggest-header { + align-items: center; + display: flex; + justify-content: space-between; + width: 100%; +} + +button.ep-autosuggest-close { + align-items: center; + display: flex; + height: 30px; + justify-content: center; + padding: 0; + position: absolute; + right: 0; + text-transform: uppercase; + top: 0; + width: 30px; +} diff --git a/assets/js/autosuggest-v2/components/AutosuggestUI.js b/assets/js/autosuggest-v2/components/AutosuggestUI.js new file mode 100644 index 0000000000..ce99fff504 --- /dev/null +++ b/assets/js/autosuggest-v2/components/AutosuggestUI.js @@ -0,0 +1,104 @@ +import { useContext, useState, useEffect } from 'react'; +import { useApiSearch } from '../../api-search'; +import { AutosuggestContext } from '../src/context'; +import SuggestionItem from './SuggestionItem'; +import SuggestionList from './SuggestionList'; +import FilterTabs from './FilterTabs'; +import useAutosuggestInput from '../src/useAutosuggestInput'; + +// Main Autosuggest UI component +const AutosuggestUI = ({ + inputEl, // DOM node of the input + minLength = 2, + perPage = 3, + // ...props +}) => { + const { searchResults, searchFor } = useApiSearch(); + + const [inputValue, setInputValue] = useState(''); + const [activeIndex, setActiveIndex] = useState(-1); + const [show, setShow] = useState(false); + // eslint-disable-next-line @wordpress/no-unused-vars-before-return + const [expanded, setExpanded] = useState(false); + const [activeFilter, setActiveFilter] = useState(null); + + const { SuggestionItemTemplate = SuggestionItem, SuggestionListTemplate = SuggestionList } = + useContext(AutosuggestContext); + + // Map searchResults to suggestions, filter by activeFilter if set + const suggestions = (searchResults || []) + .filter((hit) => !activeFilter || hit._source.type === activeFilter) + .map((hit) => ({ + id: hit._source.ID, + title: hit._source.post_title, + url: hit._source.permalink, + // thumbnail: hit._source.thumbnail, + // category: hit.category, + })); + + // Handle input events and keyboard navigation + useAutosuggestInput({ + inputEl, + minLength, + show, + setShow, + inputValue, + setInputValue, + suggestions, + activeIndex, + setActiveIndex, + searchFor, + }); + + const handleFilterChange = (filterValue) => { + setActiveFilter(filterValue); + setExpanded(false); + }; + + const handleItemClick = (idx) => { + window.location.href = suggestions[idx].url; + setShow(false); + }; + + const handleClose = () => setShow(false); + + const handleViewAll = () => setExpanded((prev) => !prev); + + // Position the dropdown absolutely under the input + // eslint-disable-next-line @wordpress/no-unused-vars-before-return + const [dropdownStyle, setDropdownStyle] = useState({}); + useEffect(() => { + if (!inputEl || !show) return; + const rect = inputEl.getBoundingClientRect(); + setDropdownStyle({ + position: 'absolute', + top: rect.bottom + window.scrollY, + left: rect.left + window.scrollX, + width: rect.width, + zIndex: 100, + }); + }, [inputEl, show, inputValue]); + + // Only show dropdown if show is true and there are suggestions + if (!show || suggestions.length === 0) { + return null; + } + + return ( +
+ + perPage} + onViewAll={handleViewAll} + expanded={expanded} + /> +
+ ); +}; + +export default AutosuggestUI; diff --git a/assets/js/autosuggest-v2/components/FilterTabs.js b/assets/js/autosuggest-v2/components/FilterTabs.js new file mode 100644 index 0000000000..a0884f3234 --- /dev/null +++ b/assets/js/autosuggest-v2/components/FilterTabs.js @@ -0,0 +1,43 @@ +// Filter tabs component for content type filtering +const FilterTabs = ({ activeFilter, onFilterChange }) => { + // Filter UI (Articles, Videos, Discussions) + const filterTabs = [ + { label: window.epasI18n?.articles || 'Articles', value: 'article' }, + { label: window.epasI18n?.videos || 'Videos', value: 'video' }, + { + label: window.epasI18n?.discussions || 'Discussions', + value: 'discussion', + }, + ]; + + return ( +
+ {filterTabs.map((tab) => ( + + ))} + +
+ ); +}; + +export default FilterTabs; diff --git a/assets/js/autosuggest-v2/components/SuggestionItem.js b/assets/js/autosuggest-v2/components/SuggestionItem.js new file mode 100644 index 0000000000..60f4fa4616 --- /dev/null +++ b/assets/js/autosuggest-v2/components/SuggestionItem.js @@ -0,0 +1,23 @@ +// Default Suggestion Item Template +const SuggestionItem = ({ suggestion, isActive, onClick }) => ( +
  • + + {suggestion.thumbnail && ( + + )} + {suggestion.title} + {suggestion.category && ( + {suggestion.category} + )} + +
  • +); + +export default SuggestionItem; diff --git a/assets/js/autosuggest-v2/components/SuggestionList.js b/assets/js/autosuggest-v2/components/SuggestionList.js new file mode 100644 index 0000000000..83f30ceaa0 --- /dev/null +++ b/assets/js/autosuggest-v2/components/SuggestionList.js @@ -0,0 +1,44 @@ +// Default Suggestion List Template +const SuggestionList = ({ + suggestions, + activeIndex, + onItemClick, + onClose, + SuggestionItemTemplate, + showViewAll, + onViewAll, + expanded, +}) => ( +
    +
    + {window.epasI18n?.searchIn || 'Search in'} + +
    + + {showViewAll && ( + + )} +
    +); + +export default SuggestionList; diff --git a/assets/js/autosuggest-v2/index.js b/assets/js/autosuggest-v2/index.js new file mode 100644 index 0000000000..aa51831ee8 --- /dev/null +++ b/assets/js/autosuggest-v2/index.js @@ -0,0 +1,166 @@ +import { createRoot } from '@wordpress/element'; +import { ApiSearchProvider } from '../api-search'; +import { apiEndpoint, apiHost, argsSchema, paramPrefix, requestIdBase } from './src/config'; +import { AutosuggestContext } from './src/context'; +import AutosuggestUI from './components/AutosuggestUI'; +import SuggestionItem from './components/SuggestionItem'; +import SuggestionList from './components/SuggestionList'; + +/** + * Mounts an Autosuggest component on a search input element. + * + * @param {HTMLInputElement} input - The search input element to attach the dropdown to. + * @param {object} apiConfig - Configuration for the search API. + * @param {string} apiConfig.apiEndpoint - Path of the API endpoint. + * @param {string} apiConfig.apiHost - Hostname or base URL of the API. + * @param {object} apiConfig.argsSchema - Schema defining allowed query arguments. + * @param {string} apiConfig.paramPrefix - Prefix to use for query parameters. + * @param {string} apiConfig.requestIdBase - Base string for generating request IDs. + * @param {object} [contextValue={}] - Optional context value passed into the Autosuggest context. + * + * @example + * const input = document.querySelector('#search-input'); + * mountAutosuggestOnInput(input, { + * apiEndpoint: '/wp/v2/search', + * apiHost: 'https://example.com', + * argsSchema: { term: 'string', per_page: 'number' }, + * paramPrefix: 's', + * requestIdBase: 'autosuggest-' + * }); + */ +function mountAutosuggestOnInput(input, apiConfig, contextValue = {}) { + if (input.dataset.epAutosuggestMounted) return; + input.dataset.epAutosuggestMounted = '1'; + + const container = document.createElement('div'); + container.className = 'ep-autosuggest-dropdown-container'; + // Insert after the input + input.parentNode.insertBefore(container, input.nextSibling); + + // Render the dropdown container + const render = () => { + createRoot(container).render( + + + + + , + ); + }; + + // Only render on input/focus, not on page load + input.addEventListener('input', render); + input.addEventListener('focus', render); +} + +/** + * Initializes autosuggest on all matching search inputs and observes for dynamically added ones. + * + * @param {object} [apiConfig={}] - Configuration passed through to `mountAutosuggestOnInput`. + * @param {string} [apiConfig.apiEndpoint] - API endpoint path for search. + * @param {string} [apiConfig.apiHost] - Host or base URL for API requests. + * @param {object} [apiConfig.argsSchema] - Schema defining allowed query arguments. + * @param {string} [apiConfig.paramPrefix] - Prefix to use for query parameters. + * @param {string} [apiConfig.requestIdBase] - Base string for generating request IDs. + * @returns {MutationObserver} The observer instance used to watch for new search inputs. + * + * @example + * // Start autosuggest on current and future search fields + * const observer = initialize({ + * apiEndpoint: '/wp/v2/search', + * apiHost: 'https://example.com', + * argsSchema: { term: 'string' }, + * paramPrefix: 's', + * requestIdBase: 'autosuggest-', + * }); + * + * // Later, to stop observing: + * observer.disconnect(); + */ + +function initialize(apiConfig = {}) { + // Find and mount on existing search inputs + document + .querySelectorAll('input[type="search"], .ep-autosuggest, .search-field') + .forEach((input) => { + if (input.tagName === 'INPUT' && input.type === 'search') { + mountAutosuggestOnInput(input, apiConfig); + } else if ( + input.classList && + (input.classList.contains('ep-autosuggest') || + input.classList.contains('search-field')) && + input.tagName === 'INPUT' + ) { + mountAutosuggestOnInput(input, apiConfig); + } + }); + + // Observe for dynamically added search fields + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if ( + node.nodeType === 1 && + node.tagName === 'INPUT' && + (node.type === 'search' || + node.classList.contains('ep-autosuggest') || + node.classList.contains('search-field')) + ) { + mountAutosuggestOnInput(node, apiConfig); + } + }); + }); + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + return observer; // Return for potential cleanup +} + +// Initialize with default config from window globals +const apiConfig = { + apiEndpoint: window.epasApiEndpoint || '/api/v1/search/posts/my-index', + apiHost: window.epasApiHost || '', + argsSchema: window.epasArgsSchema || {}, + paramPrefix: window.epasParamPrefix || '', + requestIdBase: window.epasRequestIdBase || '', +}; + +// Auto-initialize if not in a module environment +if (typeof window !== 'undefined') { + initialize(apiConfig); + + // Expose for testing or external use + window.EPAutosuggest = { + initialize, + AutosuggestUI, + AutosuggestContext, + mountAutosuggestOnInput, + SuggestionItem, + SuggestionList, + }; +} + +export { + AutosuggestUI, + AutosuggestContext, + mountAutosuggestOnInput, + SuggestionItem, + SuggestionList, + initialize, +}; + +export default { + initialize, + mountAutosuggestOnInput, +}; diff --git a/assets/js/autosuggest-v2/src/config.js b/assets/js/autosuggest-v2/src/config.js new file mode 100644 index 0000000000..5c55d77bf2 --- /dev/null +++ b/assets/js/autosuggest-v2/src/config.js @@ -0,0 +1,83 @@ +/** + * WordPress dependencies. + */ +import { __ } from '@wordpress/i18n'; + +/** + * Window dependencies. + */ +const { + apiEndpoint, + apiHost, + argsSchema, + currencyCode, + facets, + isWooCommerce, + locale, + matchType, + paramPrefix, + postTypeLabels, + taxonomyLabels, + termCount, + requestIdBase, + showSuggestions, + suggestionsBehavior, +} = window.epAutosuggestV2; + +/** + * Sorting options configuration. + */ +const sortOptions = { + relevance_desc: { + name: __('Most relevant', 'elasticpress'), + orderby: 'relevance', + order: 'desc', + currencyCode, + }, + date_desc: { + name: __('Date, newest to oldest', 'elasticpress'), + orderby: 'date', + order: 'desc', + }, + date_asc: { + name: __('Date, oldest to newest', 'elasticpress'), + orderby: 'date', + order: 'asc', + }, +}; + +/** + * Sort by price is only available for WooCommerce. + */ +if (isWooCommerce) { + sortOptions.price_desc = { + name: __('Price, highest to lowest', 'elasticpress'), + orderby: 'price', + order: 'desc', + }; + + sortOptions.price_asc = { + name: __('Price, lowest to highest', 'elasticpress'), + orderby: 'price', + order: 'asc', + }; +} + +export { + apiEndpoint, + apiHost, + argsSchema, + currencyCode, + facets, + isWooCommerce, + locale, + matchType, + paramPrefix, + postTypeLabels, + sortOptions, + taxonomyLabels, + termCount, + requestIdBase, + showSuggestions, + suggestionsBehavior, +}; diff --git a/assets/js/autosuggest-v2/src/context.js b/assets/js/autosuggest-v2/src/context.js new file mode 100644 index 0000000000..5ffa92e007 --- /dev/null +++ b/assets/js/autosuggest-v2/src/context.js @@ -0,0 +1,7 @@ +import React from 'react'; + +// Context for extensibility - allows custom templates for suggestions +export const AutosuggestContext = React.createContext({ + SuggestionItemTemplate: null, + SuggestionListTemplate: null, +}); diff --git a/assets/js/autosuggest-v2/src/useAutosuggestInput.js b/assets/js/autosuggest-v2/src/useAutosuggestInput.js new file mode 100644 index 0000000000..f4a77eb7fb --- /dev/null +++ b/assets/js/autosuggest-v2/src/useAutosuggestInput.js @@ -0,0 +1,119 @@ +import { useEffect } from 'react'; + +/** + * Attaches autosuggest behavior and keyboard navigation to an input element. + * + * @param {object} options - Hook options. + * @param {HTMLInputElement|null} options.inputEl - Input element to attach handlers to. + * @param {number} options.minLength - Minimum characters before triggering a search. + * @param {boolean} options.show - Whether the suggestions dropdown is visible. + * @param {function(boolean):void} options.setShow - Setter for dropdown visibility. + * @param {string} options.inputValue - Current value of the input field. + * @param {function(string):void} options.setInputValue - Setter for the input value state. + * @param {Array<{url: string}>} options.suggestions - Array of suggestion objects. + * @param {number} options.activeIndex - Index of the currently highlighted suggestion. + * @param {function(number):void} options.setActiveIndex - Setter for the active suggestion index. + * @param {function(string):void} options.searchFor - Function to perform the search given input. + * @returns {void} + * + * @example + * useAutosuggestInput({ + * inputEl, + * minLength: 3, + * show, + * setShow, + * inputValue, + * setInputValue, + * suggestions, + * activeIndex, + * setActiveIndex, + * searchFor, + * }); + */ +const useAutosuggestInput = ({ + inputEl, + minLength, + show, + setShow, + inputValue, + setInputValue, + suggestions, + activeIndex, + setActiveIndex, + searchFor, +}) => { + // Attach handlers to the input DOM node + useEffect(() => { + if (!inputEl) { + // no listeners to clean up + return () => {}; + } + + const handleInputChange = (e) => { + const { value } = e.target; + setInputValue(value); + setActiveIndex(-1); + if (value.length >= minLength) { + searchFor(value); + setShow(true); + } else { + setShow(false); + } + }; + + const handleKeyDown = (e) => { + if (!show) return; + if (e.key === 'ArrowDown') { + setActiveIndex((prev) => Math.min(prev + 1, suggestions.length - 1)); + e.preventDefault(); + } else if (e.key === 'ArrowUp') { + setActiveIndex((prev) => Math.max(prev - 1, 0)); + e.preventDefault(); + } else if (e.key === 'Enter' && activeIndex >= 0) { + window.location.href = suggestions[activeIndex].url; + setShow(false); + } else if (e.key === 'Escape') { + setShow(false); + } + }; + + const handleFocus = () => { + if (inputEl.value.length >= minLength && suggestions.length > 0) { + setShow(true); + } + }; + + const handleBlur = () => setTimeout(() => setShow(false), 200); + + inputEl.addEventListener('input', handleInputChange); + inputEl.addEventListener('keydown', handleKeyDown); + inputEl.addEventListener('focus', handleFocus); + inputEl.addEventListener('blur', handleBlur); + + return () => { + inputEl.removeEventListener('input', handleInputChange); + inputEl.removeEventListener('keydown', handleKeyDown); + inputEl.removeEventListener('focus', handleFocus); + inputEl.removeEventListener('blur', handleBlur); + }; + }, [ + inputEl, + minLength, + show, + suggestions, + activeIndex, + setActiveIndex, + setInputValue, + setShow, + searchFor, + ]); + + // Keep input value in sync if changed externally + useEffect(() => { + if (inputEl && inputEl.value !== inputValue) { + setInputValue(inputEl.value); + } + }, [inputEl, inputValue, setInputValue]); +}; + +export default useAutosuggestInput; diff --git a/elasticpress.php b/elasticpress.php index a51ffd4221..6e756d623c 100644 --- a/elasticpress.php +++ b/elasticpress.php @@ -162,6 +162,10 @@ function register_indexable_posts() { new Feature\Autosuggest\Autosuggest() ); + Features::factory()->register_feature( + new Feature\AutosuggestV2\AutosuggestV2() + ); + Features::factory()->register_feature( new Feature\DidYouMean\DidYouMean() ); diff --git a/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php b/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php new file mode 100644 index 0000000000..529b63e7ea --- /dev/null +++ b/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php @@ -0,0 +1,1220 @@ +slug = 'autosuggest-v2'; + + $this->host = trailingslashit( Utils\get_host() ); + + $this->index = Indexables::factory()->get( 'post' )->get_index_name(); + + $this->requires_feature = 'instant-results'; + + $this->is_woocommerce = function_exists( 'WC' ); + + $this->requires_install_reindex = true; + + $this->default_settings = array( + 'endpoint_url' => '', + 'autosuggest_selector' => '', + 'trigger_ga_event' => '0', + ); + + $this->settings = $this->get_settings(); + + $this->available_during_installation = true; + + $this->is_powered_by_epio = Utils\is_epio(); + + parent::__construct(); + } + + /** + * Sets i18n strings. + * + * @return void + * @since 5.2.0 + */ + public function set_i18n_strings(): void { + $this->title = esc_html__( 'Autosuggest V2', 'elasticpress' ); + + $this->group = esc_html__( 'Live Search', 'elasticpress' ); + + $this->short_title = esc_html__( 'Autosuggest V2', 'elasticpress' ); + + $this->summary = '

    ' . __( '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' ) . '

    '; + + $this->docs_url = __( 'https://www.elasticpress.io/documentation/article/configuring-elasticpress-via-the-plugin-dashboard/#autosuggest', 'elasticpress' ); + } + + /** + * Output feature box long + * + * @since 2.4 + */ + public function output_feature_box_long() { + ?> +

    + get_settings(); + ?> +
    +
    +
    + +

    +
    +
    + +
    +
    +
    +
    + +

    +
    +
    + epio_allowed_parameters(); + return; + } + + $endpoint_url = ( defined( 'EP_AUTOSUGGEST_ENDPOINT' ) && EP_AUTOSUGGEST_ENDPOINT ) ? EP_AUTOSUGGEST_ENDPOINT : $settings['endpoint_url']; + ?> + +
    +
    +
    + value="" type="text" name="settings[endpoint_url]" id="feature_autosuggest_endpoint_url"> + + +

    + + +

    +
    +
    + + get( 'post' ); + + $mapping = $post_indexable->add_ngram_analyzer( $mapping ); + $mapping = $post_indexable->add_term_suggest_field( $mapping ); + + // Note the assignment by reference below. + if ( version_compare( (string) Elasticsearch::factory()->get_elasticsearch_version(), '7.0', '<' ) ) { + $mapping_properties = &$mapping['mappings']['post']['properties']; + } else { + $mapping_properties = &$mapping['mappings']['properties']; + } + + $text_type = $mapping_properties['post_content']['type']; + + $mapping_properties['post_title']['fields']['suggest'] = array( + 'type' => $text_type, + 'analyzer' => 'edge_ngram_analyzer', + 'search_analyzer' => 'standard', + ); + + return $mapping; + } + + /** + * Ensure both search and autosuggest use fuziness with type auto + * + * @param integer $fuzziness Fuzziness + * @param array $search_fields Search Fields + * @param array $args Array of ES args + * @return array + */ + public function set_fuzziness( $fuzziness, $search_fields, $args ) { + if ( Utils\is_integrated_request( $this->slug, $this->get_contexts() ) && ! empty( $args['s'] ) ) { + return 'auto'; + } + return $fuzziness; + } + + /** + * Handle ngram search fields for fuzziness fields + * + * @param array $query ES Query arguments + * @param string $post_type Post Type + * @param array $args WP_Query args + * @return array $query adjusted ES Query arguments + */ + public function adjust_fuzzy_fields( $query, $post_type, $args ) { + if ( ! Utils\is_integrated_request( $this->slug, $this->get_contexts() ) || empty( $args['s'] ) ) { + return $query; + } + + if ( ! isset( $query['bool'] ) || ! isset( $query['bool']['must'] ) ) { + return $query; + } + + /** + * Filter autosuggest ngram fields + * + * @hook ep_autosuggest_ngram_fields + * @param {array} $fields Fields available to ngram + * @return {array} New fields array + */ + $ngram_fields = apply_filters( + 'ep_autosuggest_ngram_fields', + array( + 'post_title' => 'post_title.suggest', + 'terms\.(.+)\.name' => 'term_suggest', + ) + ); + + /** + * At this point, `$query` might look like this (using the 3.5 search algorithm): + * + * [ + * [bool] => [ + * [must] => [ + * [0] => [ + * [bool] => [ + * [should] => [ + * [0] => [ + * [multi_match] => [ + * [query] => ep_autosuggest_placeholder + * [type] => phrase + * [fields] => [ + * [0] => post_title^1 + * ... + * [n] => terms.category.name^27 + * ] + * [boost] => 3 + * ] + * ] + * [1] => [ + * [multi_match] => [ + * [query] => ep_autosuggest_placeholder + * [fields] => [ ... ] + * [type] => phrase + * [slop] => 5 + * ] + * ] + * ] + * ] + * ] + * ] + * ] + * ... + * ] + * + * Also, note the usage of `&$must_query`. This means that by changing `$must_query` + * you will be actually changing `$query`. + */ + foreach ( $query['bool']['must'] as &$must_query ) { + if ( ! isset( $must_query['bool'] ) || ! isset( $must_query['bool']['should'] ) ) { + continue; + } + foreach ( $must_query['bool']['should'] as &$current_bool_should ) { + if ( ! isset( $current_bool_should['multi_match'] ) || ! isset( $current_bool_should['multi_match']['fields'] ) ) { + continue; + } + + /** + * `fuzziness` is used in the original algorithm. + * `slop` is used in `3.5`. + * + * @see \ElasticPress\Indexable\Post\Post::format_args() + */ + if ( empty( $current_bool_should['multi_match']['fuzziness'] ) && empty( $current_bool_should['multi_match']['slop'] ) ) { + continue; + } + + $fields_to_add = array(); + + /** + * If the regex used in `$ngram_fields` matches more than one field, + * like taxonomies, for example, we use the min value - 1. + */ + foreach ( $current_bool_should['multi_match']['fields'] as $field ) { + foreach ( $ngram_fields as $regex => $ngram_field ) { + if ( preg_match( '/^(' . $regex . ')(\^(\d+))?$/', $field, $match ) ) { + $weight = 1; + if ( isset( $match[4] ) && $match[4] > 1 ) { + $weight = $match[4] - 1; + } + + if ( isset( $fields_to_add[ $ngram_field ] ) ) { + $fields_to_add[ $ngram_field ] = min( $fields_to_add[ $ngram_field ], $weight ); + } else { + $fields_to_add[ $ngram_field ] = $weight; + } + } + } + } + + foreach ( $fields_to_add as $field => $weight ) { + $current_bool_should['multi_match']['fields'][] = "{$field}^{$weight}"; + } + } + } + + return $query; + } + + /** + * Add term suggestions to be indexed + * + * @param array $post_args Array of ES args. + * @since 2.4 + * @return array + */ + public function filter_term_suggest( $post_args ) { + $suggest = array(); + + if ( ! empty( $post_args['terms'] ) ) { + foreach ( $post_args['terms'] as $taxonomy ) { + foreach ( $taxonomy as $term ) { + $suggest[] = $term['name']; + } + } + } + + if ( ! empty( $suggest ) ) { + $post_args['term_suggest'] = $suggest; + } + + return $post_args; + } + + /** + * Enqueue our autosuggest script + * + * @since 2.4 + */ + public function enqueue_scripts() { + if ( Utils\is_indexing() ) { + return; + } + + $host = Utils\get_host(); + $settings = $this->get_settings(); + + if ( defined( 'EP_AUTOSUGGEST_ENDPOINT' ) && EP_AUTOSUGGEST_ENDPOINT ) { + $endpoint_url = EP_AUTOSUGGEST_ENDPOINT; + } elseif ( Utils\is_epio() ) { + $endpoint_url = trailingslashit( $host ) . Indexables::factory()->get( 'post' )->get_index_name() . '/autosuggest'; + } else { + $endpoint_url = $settings['endpoint_url']; + } + + if ( empty( $endpoint_url ) ) { + return; + } + + 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' ); + + wp_enqueue_style( + 'elasticpress-autosuggest-v2', + EP_URL . 'dist/css/autosuggest-v2-styles.css', + Utils\get_asset_info( 'autosuggest-styles', 'dependencies' ), + Utils\get_asset_info( 'autosuggest-styles', 'version' ) + ); + + /** Features Class @var Features $features */ + $features = Features::factory(); + + /** Search Feature @var Feature\Search\Search $search */ + $search = $features->get_registered_feature( 'search' ); + + $query = $this->generate_search_query(); + + $epas_options = array( + 'query' => $query['body'], + 'placeholder' => $query['placeholder'], + 'endpointUrl' => esc_url( untrailingslashit( $endpoint_url ) ), + 'selector' => empty( $settings['autosuggest_selector'] ) ? 'ep-autosuggest' : esc_html( $settings['autosuggest_selector'] ), + /** + * Filter autosuggest default selectors. + * + * @hook ep_autosuggest_default_selectors + * @since 3.6.0 + * @param {string} $selectors Default selectors used to attach autosuggest. + * @return {string} Selectors used to attach autosuggest. + */ + 'defaultSelectors' => apply_filters( 'ep_autosuggest_default_selectors', '.ep-autosuggest, input[type="search"], .search-field' ), + 'action' => 'navigate', + 'mimeTypes' => array(), + /** + * Filter autosuggest HTTP headers + * + * @hook ep_autosuggest_http_headers + * @param {array} $headers Autosuggest HTTP headers in name => value format + * @return {array} HTTP headers + */ + 'http_headers' => apply_filters( 'ep_autosuggest_http_headers', array() ), + 'triggerAnalytics' => ! empty( $settings['trigger_ga_event'] ), + 'addSearchTermHeader' => false, + 'requestIdBase' => Utils\get_request_id_base(), + ); + + if ( Utils\is_epio() ) { + $epas_options['addSearchTermHeader'] = true; + } + + $search_settings = $search->get_settings(); + + if ( ! $search_settings ) { + $search_settings = array(); + } + + $search_settings = wp_parse_args( $search_settings, $search->default_settings ); + + if ( ! empty( $search_settings ) && $search_settings['highlight_enabled'] ) { + $epas_options['highlightingEnabled'] = true; + $epas_options['highlightingTag'] = apply_filters( 'ep_highlighting_tag', $search_settings['highlight_tag'] ); + $epas_options['highlightingClass'] = apply_filters( 'ep_highlighting_class', 'ep-highlight' ); + } + + /** + * Output variables to use in Javascript + * index: the Elasticsearch index name + * endpointUrl: the Elasticsearch autosuggest endpoint url + * postType: which post types to use for suggestions + * action: the action to take when selecting an item. Possible values are "search" and "navigate". + */ + $api_endpoint = apply_filters( 'ep_instant_results_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, + 'facets' => $this->get_facets_for_frontend(), + 'highlightTag' => $this->settings['highlight_tag'] ?? false, + 'isWooCommerce' => $this->is_woocommerce, + 'locale' => str_replace( '_', '-', get_locale() ), + 'matchType' => $this->settings['match_type'] ?? false, + 'paramPrefix' => 'ep-', + 'postTypeLabels' => $this->get_post_type_labels(), + 'termCount' => $this->settings['term_count'] ?? false, + 'requestIdBase' => Utils\get_request_id_base(), + 'showSuggestions' => \ElasticPress\Features::factory()->get_registered_feature( 'did-you-mean' )->is_active(), + 'suggestionsBehavior' => $this->settings['search_behavior'] ?? false, + ) + ); + } + + /** + * Get schema for search args. + * + * @return array Search args schema. + */ + public function get_args_schema() { + /** + * The number of results per page for Instant Results. + * + * @since 4.5.0 + * @hook ep_instant_results_per_page + * @param {int} $per_page Results per page. + */ + $per_page = apply_filters( 'ep_instant_results_per_page', $this->settings['per_page'] ?? '3' ); + + $args_schema = array( + 'highlight' => array( + 'type' => 'string', + 'default' => $this->settings['highlight_tag'] ?? false, + 'allowedValues' => array( $this->settings['highlight_tag'] ?? false ), + ), + 'offset' => array( + 'type' => 'number', + 'default' => 0, + ), + 'orderby' => array( + 'type' => 'string', + 'default' => 'relevance', + 'allowedValues' => array( 'date', 'price', 'relevance' ), + ), + 'order' => array( + 'type' => 'string', + 'default' => 'desc', + 'allowedValues' => array( 'asc', 'desc' ), + ), + 'per_page' => array( + 'type' => 'number', + 'default' => absint( $per_page ?? 3 ), + ), + 'post_type' => array( + 'type' => 'strings', + ), + 'search' => array( + 'type' => 'string', + 'default' => '', + ), + 'relation' => array( + 'type' => 'string', + 'default' => 'all' === ( $this->settings['match_type'] ?? false ) ? 'and' : 'or', + 'allowedValues' => array( 'and', 'or' ), + ), + ); + + $selected_facets = explode( ',', $this->settings['facets'] ?? '' ); + $available_facets = $this->get_facets(); + + foreach ( $selected_facets as $key ) { + if ( isset( $available_facets[ $key ] ) ) { + $args_schema = array_merge( $args_schema, $available_facets[ $key ]['args'] ); + } + } + + /** + * The schema defining the API arguments used by Instant Results. + * + * The argument schema is used to configure the APISearchProvider + * component used by Instant Results, and should conform to what is + * supported by the API being used. The Instant Results UI expects + * the default list of arguments to be available, so caution is advised + * when adding or removing arguments. + * + * @since 4.5.1 + * @hook ep_instant_results_args_schema + * @param {array} $args_schema Results per page. + */ + return apply_filters( 'ep_instant_results_args_schema', $args_schema ); + } + + /** + * Get facet configuration for the front end. + * + * @return Array Facet configuration for the front end. + */ + public function get_facets_for_frontend() { + $selected_facets = explode( ',', $this->settings['facets'] ?? '' ); + $available_facets = $this->get_facets(); + + $facets = array(); + + foreach ( $selected_facets as $key ) { + if ( isset( $available_facets[ $key ] ) ) { + $facet = $available_facets[ $key ]; + + $facets[] = array( + 'name' => $key, + 'label' => $facet['labels']['frontend'], + 'type' => $facet['type'], + 'postTypes' => $facet['post_types'], + ); + } + } + + return $facets; + } + + /** + * Get available facets. + * + * @return array Available facets. + */ + public function get_facets() { + $facets = array(); + + /** + * Post type facet. + */ + $facets['post_type'] = array( + 'type' => 'post_type', + 'post_types' => array(), + 'labels' => array( + 'admin' => __( 'Post type', 'elasticpress' ), + 'frontend' => __( 'Type', 'elasticpress' ), + ), + 'aggs' => array( + 'post_type' => array( + 'terms' => array( + 'field' => 'post_type.raw', + ), + ), + ), + /** + * The post_type arg needs to be supported regardless of whether + * the Post Type facet is present to be able to support setting the + * post type from the search form. + * + * @see ElasticPress\Feature\InstantResults::get_args_schema() + */ + 'args' => array(), + ); + + /** + * Taxonomy facets. + */ + $taxonomies = get_taxonomies( array( 'public' => true ), 'object' ); + $taxonomies = apply_filters( 'ep_facet_include_taxonomies', $taxonomies ); + + foreach ( $taxonomies as $slug => $taxonomy ) { + $name = 'tax-' . $slug; + $labels = get_taxonomy_labels( $taxonomy ); + + $admin_label = sprintf( + /* translators: $1$s: Taxonomy name. %2$s: Taxonomy slug. */ + esc_html__( '%1$s (%2$s)' ), + $labels->singular_name, + $slug + ); + + $post_types = Features::factory()->get_registered_feature( 'search' )->get_searchable_post_types(); + $post_types = array_intersect( $post_types, $taxonomy->object_type ); + $post_types = array_values( $post_types ); + + $facets[ $name ] = array( + 'type' => 'taxonomy', + 'post_types' => $post_types, + 'labels' => array( + 'admin' => $admin_label, + 'frontend' => $labels->singular_name, + ), + 'aggs' => array( + $name => array( + 'terms' => array( + 'field' => 'terms.' . $slug . '.facet', + 'size' => apply_filters( 'ep_facet_taxonomies_size', 10000, $taxonomy ), + ), + ), + ), + 'args' => array( + $name => array( + 'type' => 'strings', + ), + ), + ); + } + + /** + * Price facet. + */ + if ( $this->is_woocommerce ) { + $facets['price_range'] = array( + 'type' => 'price_range', + 'post_types' => array( 'product' ), + 'labels' => array( + 'admin' => __( 'Price range', 'elasticpress' ), + 'frontend' => __( 'Price', 'elasticpress' ), + ), + 'aggs' => array( + 'max_price' => array( + 'max' => array( + 'field' => 'meta._price.double', + ), + ), + 'min_price' => array( + 'min' => array( + 'field' => 'meta._price.double', + ), + ), + ), + 'args' => array( + 'max_price' => array( + 'type' => 'number', + ), + 'min_price' => array( + 'type' => 'number', + ), + ), + ); + } + + return $facets; + } + + /** + * Get post type labels. + * + * Only the post type slug is indexed, so we'll need the labels on the + * front end for display. + * + * @return array Array of post types and their labels. + */ + public function get_post_type_labels() { + $labels = array(); + + $post_types = Features::factory()->get_registered_feature( 'search' )->get_searchable_post_types(); + + foreach ( $post_types as $post_type ) { + $post_type_object = get_post_type_object( $post_type ); + $post_type_labels = get_post_type_labels( $post_type_object ); + + $labels[ $post_type ] = array( + 'plural' => $post_type_labels->name, + 'singular' => $post_type_labels->singular_name, + ); + } + + return $labels; + } + + /** + * Build a default search request to pass to the autosuggest javascript. + * The request will include a placeholder that can then be replaced. + * + * @return array Generated ElasticSearch request array( 'placeholder'=> placeholderstring, 'body' => request body ) + */ + public function generate_search_query() { + + /** + * Filter autosuggest query placeholder + * + * @hook ep_autosuggest_query_placeholder + * @param {string} $placeholder Autosuggest placeholder to be replaced later + * @return {string} New placeholder + */ + $placeholder = apply_filters( 'ep_autosuggest_query_placeholder', 'ep_autosuggest_placeholder' ); + + /** Features Class @var Features $features */ + $features = Features::factory(); + + $post_type = $features->get_registered_feature( 'search' )->get_searchable_post_types(); + + /** + * Filter post types available to autosuggest + * + * @hook ep_term_suggest_post_type + * @param {array} $post_types Post types + * @return {array} New post types + */ + $post_type = apply_filters( 'ep_term_suggest_post_type', array_values( $post_type ) ); + + $post_status = get_post_stati( + array( + 'public' => true, + 'exclude_from_search' => false, + ) + ); + + /** + * Filter post statuses available to autosuggest + * + * @hook ep_term_suggest_post_status + * @param {array} $post_statuses Post statuses + * @return {array} New post statuses + */ + $post_status = apply_filters( 'ep_term_suggest_post_status', array_values( $post_status ) ); + + add_filter( 'ep_intercept_remote_request', array( $this, 'intercept_remote_request' ) ); + add_filter( 'ep_weighting_configuration', array( $features->get_registered_feature( $this->slug ), 'apply_autosuggest_weighting' ) ); + + add_filter( 'ep_do_intercept_request', array( $features->get_registered_feature( $this->slug ), 'intercept_search_request' ), 10, 2 ); + + add_filter( 'posts_pre_query', array( $features->get_registered_feature( $this->slug ), 'return_empty_posts' ), 100, 1 ); // after ES Query to ensure we are not falling back to DB in any case + + new \WP_Query( + /** + * Filter WP Query args of the autosuggest query template. + * + * If you want to display 20 posts in autosuggest: + * + * ``` + * add_filter( + * 'ep_autosuggest_query_args', + * function( $args ) { + * $args['posts_per_page'] = 20; + * return $args; + * } + * ); + * ``` + * + * @since 4.4.0 + * @hook ep_autosuggest_query_args + * @param {array} $args Query args + * @return {array} New query args + */ + apply_filters( + 'ep_autosuggest_query_args', + array( + 'post_type' => $post_type, + 'post_status' => $post_status, + 's' => $placeholder, + 'ep_integrate' => true, + ) + ) + ); + + remove_filter( 'posts_pre_query', array( $features->get_registered_feature( $this->slug ), 'return_empty_posts' ), 100 ); + + remove_filter( 'ep_do_intercept_request', array( $features->get_registered_feature( $this->slug ), 'intercept_search_request' ) ); + + remove_filter( 'ep_weighting_configuration', array( $features->get_registered_feature( $this->slug ), 'apply_autosuggest_weighting' ) ); + + remove_filter( 'ep_intercept_remote_request', array( $this, 'intercept_remote_request' ) ); + + return array( + 'body' => $this->autosuggest_query, + 'placeholder' => $placeholder, + ); + } + + /** + * Ensure we do not fallback to WPDB query for this request + * + * @param array $posts array of post objects + * @return array $posts + */ + public function return_empty_posts( $posts = array() ) { + return array(); + } + + /** + * Allow applying custom weighting configuration for autosuggest + * + * @param array $config current configuration + * @return array $config desired configuration + */ + public function apply_autosuggest_weighting( $config = array() ) { + /** + * Filter autosuggest weighting configuration + * + * @hook ep_weighting_configuration_for_autosuggest + * @param {array} $config Configuration + * @return {array} New config + */ + $config = apply_filters( 'ep_weighting_configuration_for_autosuggest', $config ); + return $config; + } + + /** + * Store intercepted request value and return a fake successful request result + * + * @param array $response Response + * @param array $query ES Query + * @return array $response Response + */ + public function intercept_search_request( $response, $query = array() ) { + $this->autosuggest_query = $query['args']['body']; + + $message = wp_json_encode( + array( + esc_html__( 'This is a fake request to build the ElasticPress Autosuggest query. It is not really sent.', 'elasticpress' ), + ) + ); + + return array( + 'is_ep_fake_request' => true, + 'body' => $message, + 'response' => array( + 'code' => 200, + 'message' => $message, + ), + ); + } + + /** + * Tell user whether requirements for feature are met or not. + * + * @return array $status Status array + * @since 2.4 + */ + public function requirements_status() { + $status = new FeatureRequirementsStatus( 0 ); + + $status->message = array(); + + $status->message[] = esc_html__( 'This feature modifies the site’s default user experience by presenting a list of suggestions below detected search fields as text is entered into the field.', 'elasticpress' ); + + if ( ! Utils\is_epio() ) { + $status->code = 1; + $status->message[] = wp_kses_post( __( "You aren't using ElasticPress.io so we can't be sure your host is properly secured. Autosuggest requires a publicly accessible endpoint, which can expose private content and allow data modification if improperly configured.", 'elasticpress' ) ); + } + + return $status; + } + + /** + * Do a non-blocking search query to force the autosuggest hash to update. + * + * This request has to happen in a public environment, so all code testing if `is_admin()` + * are properly executed. + * + * @param bool $blocking If the request should block the execution or not. + */ + public function epio_send_autosuggest_public_request( $blocking = false ) { + if ( ! Utils\is_epio() ) { + return; + } + + $url = add_query_arg( + array( + 's' => 'search test', + 'ep_epio_set_autosuggest' => 1, + 'ep_epio_nonce' => wp_create_nonce( 'ep-epio-set-autosuggest' ), + 'nocache' => time(), // Here just to avoid the request hitting a CDN. + ), + home_url( '/' ) + ); + + // Pass the same cookies, so the same authenticated user is used (and we can check the nonce). + $cookies = array(); + foreach ( $_COOKIE as $name => $value ) { + if ( ! is_string( $name ) || ! is_string( $value ) ) { + continue; + } + + $cookies[] = new \WP_Http_Cookie( + array( + 'name' => $name, + 'value' => $value, + ) + ); + } + + wp_remote_get( + $url, + array( + 'cookies' => $cookies, + 'blocking' => (bool) $blocking, + ) + ); + } + + /** + * Send the allowed parameters for autosuggest to ElasticPress.io. + */ + public function epio_send_autosuggest_allowed() { + if ( empty( $_REQUEST['ep_epio_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_REQUEST['ep_epio_nonce'] ), 'ep-epio-set-autosuggest' ) ) { + return; + } + + if ( empty( $_GET['ep_epio_set_autosuggest'] ) ) { + return; + } + + /** + * Fires before the request is sent to EP.io to set Autosuggest allowed values. + * + * @hook ep_epio_pre_send_autosuggest_allowed + * @since 3.5.x + */ + do_action( 'ep_epio_pre_send_autosuggest_allowed' ); + + /** + * The same ES query sent by autosuggest. + * + * Sometimes it'll be a string, sometimes it'll be already an array. + */ + $es_search_query = $this->generate_search_query()['body']; + $es_search_query = ( is_array( $es_search_query ) ) ? $es_search_query : json_decode( $es_search_query, true ); + + /** + * Filter autosuggest ES query + * + * @since 3.5.x + * @hook ep_epio_autosuggest_es_query + * @param {array} The ES Query. + */ + $es_search_query = apply_filters( 'ep_epio_autosuggest_es_query', $es_search_query ); + + /** + * Here is a chance to short-circuit the execution. Also, during the sync + * the query will be empty anyway. + */ + if ( empty( $es_search_query ) ) { + return; + } + + $index = Indexables::factory()->get( 'post' )->get_index_name(); + + add_filter( 'ep_format_request_headers', array( $this, 'add_ep_set_autosuggest_header' ) ); + + Elasticsearch::factory()->query( $index, 'post', $es_search_query, array() ); + + remove_filter( 'ep_format_request_headers', array( $this, 'add_ep_set_autosuggest_header' ) ); + + /** + * Fires after the request is sent to EP.io to set Autosuggest allowed values. + * + * @hook ep_epio_sent_autosuggest_allowed + * @since 3.5.x + */ + do_action( 'ep_epio_sent_autosuggest_allowed' ); + } + + /** + * Set a header so EP.io servers know this request contains the values + * that should be stored as allowed. + * + * @since 3.5.x + * @param array $headers The Request Headers. + * @return array + */ + public function add_ep_set_autosuggest_header( $headers ) { + $headers['EP-Set-Autosuggest'] = true; + return $headers; + } + + /** + * Retrieve the allowed parameters for autosuggest from ElasticPress.io. + * + * @return array + */ + public function epio_retrieve_autosuggest_allowed() { + $response = Elasticsearch::factory()->remote_request( + Indexables::factory()->get( 'post' )->get_index_name() . '/get-autosuggest-allowed' + ); + + $body = wp_remote_retrieve_body( $response, true ); + return json_decode( $body, true ); + } + + /** + * Output the current allowed parameters for autosuggest stored in ElasticPress.io. + */ + public function epio_allowed_parameters() { + global $wp_version; + + $allowed_params = $this->epio_autosuggest_set_and_get(); + if ( empty( $allowed_params ) ) { + return; + } + ?> +
    +
    +
    + tag (ElasticPress.io); 2. ; 3: tag (KB article); 4. ; 5: tag (Site Health Debug Section); 6. ; */ + esc_html__( 'You are directly connected to %1$sElasticPress.io%2$s, ensuring the most performant Autosuggest experience. %3$sLearn more about what this means%4$s or %5$sclick here for debug information%6$s.', 'elasticpress' ), + '', + '', + '', + '', + '', + '' + ); + ?> +
    +
    + epio_retrieve_autosuggest_allowed(); + + if ( is_wp_error( $allowed_params ) || ( isset( $allowed_params['status'] ) && 200 !== $allowed_params['status'] ) ) { + $allowed_params = array(); + break; + } + + // We have what we need, no need to retry. + if ( ! empty( $allowed_params ) ) { + break; + } + + // Send to EP.io what should be autosuggest's allowed values and try to get them again. + $this->epio_send_autosuggest_public_request( true ); + } + + return $allowed_params; + } + + /** + * Return true, so EP knows we want to intercept the remote request + * + * As we add and remove this function from `ep_intercept_remote_request`, + * using `__return_true` could remove a *real* `__return_true` added by someone else. + * + * @since 4.7.0 + * @see https://github.com/10up/ElasticPress/issues/2887 + * @return true + */ + public function intercept_remote_request() { + return true; + } + + /** + * Conditionally add EP.io information to the settings schema + * + * @since 5.0.0 + */ + protected function maybe_add_epio_settings_schema() { + if ( ! Utils\is_epio() ) { + return; + } + + $epio_link = 'https://elasticpress.io'; + $epio_autosuggest_kb_link = 'https://www.elasticpress.io/documentation/article/elasticpress-io-autosuggest/'; + $status_report_link = defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ? network_admin_url( 'admin.php?page=elasticpress-status-report' ) : admin_url( 'admin.php?page=elasticpress-status-report' ); + + $this->settings_schema[] = array( + 'key' => 'epio', + 'label' => sprintf( + /* translators: 1: tag (ElasticPress.io); 2. ; 3: tag (KB article); 4. ; 5: tag (Site Health Debug Section); 6. ; */ + __( 'You are directly connected to %1$sElasticPress.io%2$s, ensuring the most performant Autosuggest experience. %3$sLearn more about what this means%4$s or %5$sclick here for debug information%6$s.', 'elasticpress' ), + '', + '', + '', + '', + '', + '' + ), + 'type' => 'markup', + ); + } + + /** + * Set the `settings_schema` attribute + * + * @since 5.0.0 + */ + protected function set_settings_schema() { + $this->settings_schema = array( + array( + 'default' => '.ep-autosuggest', + 'help' => __( 'Input additional selectors where you would like to include autosuggest, separated by a comma. Example: .custom-selector, #custom-id, input[type="text"]', 'elasticpress' ), + 'key' => 'autosuggest_selector', + 'label' => __( 'Additional selectors', 'elasticpress' ), + 'type' => 'text', + ), + array( + 'default' => '0', + 'key' => 'trigger_ga_event', + 'help' => __( 'Enable to fire a gtag tracking event when an autosuggest result is clicked.', 'elasticpress' ), + 'label' => __( 'Trigger Google Analytics events', 'elasticpress' ), + 'type' => 'checkbox', + ), + ); + + $this->maybe_add_epio_settings_schema(); + + if ( ! Utils\is_epio() ) { + $set_in_wp_config = defined( 'EP_AUTOSUGGEST_ENDPOINT' ) && EP_AUTOSUGGEST_ENDPOINT; + + $this->settings_schema[] = array( + 'disabled' => $set_in_wp_config, + 'help' => ! $set_in_wp_config ? __( 'A valid URL starting with http:// or https://. This address will be exposed to the public.', 'elasticpress' ) : '', + 'key' => 'endpoint_url', + 'label' => __( 'Endpoint URL', 'elasticpress' ), + 'type' => 'url', + ); + } + } + + /** + * DEPRECATED. Delete the cached query for autosuggest. + * + * @since 3.5.5 + */ + public function delete_cached_query() { + _doing_it_wrong( + __METHOD__, + esc_html__( 'This method should not be called anymore, as autosuggest requests are not sent regularly anymore.' ), + 'ElasticPress 4.7.0' + ); + } + + /** + * Get the contexts for autosuggest. + * + * @since 5.1.0 + * @return array + */ + protected function get_contexts(): array { + /** + * Filter contexts for autosuggest. + * + * @hook ep_autosuggest_contexts + * @since 5.1.0 + * @param {array} $contexts Contexts for autosuggest + * @return {array} New contexts + */ + return apply_filters( 'ep_autosuggest_contexts', array( 'public', 'ajax' ) ); + } +} diff --git a/package.json b/package.json index 865b3c6fe4..bc6739a431 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", From 0d3d7f224af46c26575de32082913dced8e52d61 Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Mon, 5 May 2025 21:39:15 -0500 Subject: [PATCH 02/20] Autosuggest v2 filterable, hookable react component buildout --- assets/css/autosuggest-v2.css | 11 +- assets/js/api-search/index.js | 72 +++- assets/js/api-search/src/reducer.js | 4 + .../components/AutosuggestUI.js | 157 ++++++--- .../autosuggest-v2/components/FilterTabs.js | 43 --- .../components/SuggestionItem.js | 59 ++-- .../components/SuggestionList.js | 86 ++--- assets/js/autosuggest-v2/index.js | 186 +++------- assets/js/autosuggest-v2/readme.md | 235 +++++++++++++ assets/js/autosuggest-v2/src/hooks.js | 70 ++++ .../Feature/AutosuggestV2/AutosuggestV2.php | 322 ++++++++++++++++-- 11 files changed, 923 insertions(+), 322 deletions(-) delete mode 100644 assets/js/autosuggest-v2/components/FilterTabs.js create mode 100644 assets/js/autosuggest-v2/readme.md create mode 100644 assets/js/autosuggest-v2/src/hooks.js diff --git a/assets/css/autosuggest-v2.css b/assets/css/autosuggest-v2.css index 5a0f7f9606..45bbb83fc5 100644 --- a/assets/css/autosuggest-v2.css +++ b/assets/css/autosuggest-v2.css @@ -44,8 +44,8 @@ .ep-autosuggest { background: #fff; - min-width: 500px; padding: 20px; + width: 100%; } button.ep-autosuggest-filter { @@ -71,3 +71,12 @@ button.ep-autosuggest-close { top: 0; width: 30px; } + +.ep-autosuggest-wrapper { + position: relative; +} + +.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..8285a39d62 100644 --- a/assets/js/api-search/index.js +++ b/assets/js/api-search/index.js @@ -25,6 +25,43 @@ import { getUrlWithParams, } from './src/utilities'; +/** + * Add WordPress hooks integration + */ + +/** + * Apply filters to search results + * + * @param {Array} searchResults - The original search results from API + * @param {string} searchTerm - The current search term + * @returns {Array} - The filtered search results + */ +export const applyResultsFilter = (searchResults, searchTerm) => { + // Check if wp.hooks is available (WordPress environment) + if (typeof wp !== 'undefined' && wp.hooks) { + return wp.hooks.applyFilters('ep.Autosuggest.suggestions', searchResults, searchTerm); + } + + return searchResults; +}; + +/** + * Apply filters to query parameters + * + * @param {object} params - The original query parameters + * @param {string} searchTerm - The current search term + * @param {object} filters - Any active filters + * @returns {object} - The modified query parameters + */ +export const applyQueryParamsFilter = (params, searchTerm, filters = {}) => { + // Check if wp.hooks is available (WordPress environment) + if (typeof wp !== 'undefined' && wp.hooks) { + return wp.hooks.applyFilters('ep.Autosuggest.queryParams', params, searchTerm, filters); + } + + return params; +}; + /** * Instant Results context. */ @@ -110,6 +147,7 @@ export const ApiSearchProvider = ({ suggestedTerms: [], isFirstSearch: true, searchTerm: '', + activeFilters: {}, }); /** @@ -149,16 +187,27 @@ export const ApiSearchProvider = ({ dispatch({ type: 'SEARCH', args }); }, []); + /** + * Update filters for the search + * + * @param {object} filters - Filter key-value pairs + * @returns {void} + */ + const updateFilters = useCallback((filters) => { + dispatch({ type: 'UPDATE_FILTERS', filters }); + }, []); + /** * Update the search term, triggering a search and resetting facet * constraints. * * @param {string} searchTerm Search term. + * @param {object} filters Optional filters to apply * @returns {void} */ - const searchFor = (searchTerm) => { - dispatch({ type: 'SEARCH_FOR', searchTerm }); - }; + const searchFor = useCallback((searchTerm, filters = {}) => { + dispatch({ type: 'SEARCH_FOR', searchTerm, filters }); + }, []); /** * Set loading state. @@ -285,7 +334,7 @@ export const ApiSearchProvider = ({ */ const handleSearch = useCallback(() => { const handle = async () => { - const { args, isOn, isPoppingState } = stateRef.current; + const { args, isOn, isPoppingState, activeFilters, searchTerm } = stateRef.current; if (!isPoppingState) { pushState(); @@ -295,7 +344,11 @@ export const ApiSearchProvider = ({ return; } - const urlParams = getUrlParamsFromArgs(args, argsSchema); + // Get URL parameters from args + let urlParams = getUrlParamsFromArgs(args, argsSchema); + + // Apply query parameter filters through WP hooks + urlParams = applyQueryParamsFilter(urlParams, searchTerm, activeFilters); setIsLoading(true); @@ -306,6 +359,11 @@ export const ApiSearchProvider = ({ return; } + // Apply filters to search results if hooks are available + if (response.hits && response.hits.hits) { + response.hits.hits = applyResultsFilter(response.hits.hits, searchTerm); + } + setResults(response); } catch (e) { const errorMessage = sprintf( @@ -334,6 +392,7 @@ export const ApiSearchProvider = ({ state.args.order, state.args.offset, state.args.search, + state.activeFilters, ]); /** @@ -349,12 +408,14 @@ export const ApiSearchProvider = ({ totalResults, suggestedTerms, isFirstSearch, + activeFilters, } = stateRef.current; // eslint-disable-next-line react/jsx-no-constructed-context-values const contextValue = { aggregations, args, + activeFilters, clearConstraints, clearResults, getUrlParamsFromArgs, @@ -372,6 +433,7 @@ export const ApiSearchProvider = ({ turnOff, suggestedTerms, isFirstSearch, + updateFilters, }; return {children}; diff --git a/assets/js/api-search/src/reducer.js b/assets/js/api-search/src/reducer.js index 20e924ba37..7a9e54e88f 100644 --- a/assets/js/api-search/src/reducer.js +++ b/assets/js/api-search/src/reducer.js @@ -98,6 +98,10 @@ export default (state, action) => { break; } + case 'UPDATE_FILTERS': { + newState.activeFilters = { ...newState.activeFilters, ...action.filters }; + break; + } default: break; } diff --git a/assets/js/autosuggest-v2/components/AutosuggestUI.js b/assets/js/autosuggest-v2/components/AutosuggestUI.js index ce99fff504..16afd798ac 100644 --- a/assets/js/autosuggest-v2/components/AutosuggestUI.js +++ b/assets/js/autosuggest-v2/components/AutosuggestUI.js @@ -3,7 +3,6 @@ import { useApiSearch } from '../../api-search'; import { AutosuggestContext } from '../src/context'; import SuggestionItem from './SuggestionItem'; import SuggestionList from './SuggestionList'; -import FilterTabs from './FilterTabs'; import useAutosuggestInput from '../src/useAutosuggestInput'; // Main Autosuggest UI component @@ -13,27 +12,46 @@ const AutosuggestUI = ({ perPage = 3, // ...props }) => { - const { searchResults, searchFor } = useApiSearch(); + // Use enhanced context with additional methods from API Search Provider + const { + searchResults, + searchFor, + activeFilters: contextFilters = {}, + updateFilters, + } = useApiSearch(); const [inputValue, setInputValue] = useState(''); const [activeIndex, setActiveIndex] = useState(-1); const [show, setShow] = useState(false); - // eslint-disable-next-line @wordpress/no-unused-vars-before-return const [expanded, setExpanded] = useState(false); - const [activeFilter, setActiveFilter] = useState(null); + // Local state for active filters, synced with context + const [activeFilters, setActiveFilters] = useState(contextFilters); + const [activeTypeFilter, setActiveTypeFilter] = useState(null); + + // Get customized templates from context const { SuggestionItemTemplate = SuggestionItem, SuggestionListTemplate = SuggestionList } = useContext(AutosuggestContext); - // Map searchResults to suggestions, filter by activeFilter if set + // Sync local filters with context filters when they change + useEffect(() => { + setActiveFilters(contextFilters); + }, [contextFilters]); + + // Map searchResults to suggestions, applying any active filters const suggestions = (searchResults || []) - .filter((hit) => !activeFilter || hit._source.type === activeFilter) + // First apply type filter if active + .filter((hit) => !activeTypeFilter || hit._source.post_type === activeTypeFilter) + // Then map to the format expected by suggestion components .map((hit) => ({ id: hit._source.ID, title: hit._source.post_title, url: hit._source.permalink, - // thumbnail: hit._source.thumbnail, - // category: hit.category, + type: hit._source.post_type, + thumbnail: hit._source.thumbnail || null, + category: hit._source.category || null, + // Include original source data for advanced filtering + _source: hit._source, })); // Handle input events and keyboard navigation @@ -47,56 +65,113 @@ const AutosuggestUI = ({ suggestions, activeIndex, setActiveIndex, - searchFor, + // Pass search function that includes filters + searchFor: (term) => searchFor(term, activeFilters), }); - const handleFilterChange = (filterValue) => { - setActiveFilter(filterValue); - setExpanded(false); + /** + * Update a specific filter and trigger search + * + * @param {string} filterKey - The filter key to update + * @param {any} filterValue - The filter value + */ + const handleFilterChange = (filterKey, filterValue) => { + // Update local state + const newFilters = { + ...activeFilters, + [filterKey]: filterValue, + }; + + // Update local state + setActiveFilters(newFilters); + + // If this is a type filter, also update the UI state + if (filterKey === 'post_type') { + setActiveTypeFilter(filterValue); + } + + // If expanded, collapse the view + if (expanded) { + setExpanded(false); + } + + // Update context state and trigger search + if (typeof updateFilters === 'function') { + updateFilters(newFilters); + } + + // If we have a search term, trigger a new search with the updated filters + if (inputValue && inputValue.length >= minLength) { + searchFor(inputValue, newFilters); + } + }; + + /** + * Reset all filters to default values + */ + const resetFilters = () => { + setActiveFilters({}); + setActiveTypeFilter(null); + + if (typeof updateFilters === 'function') { + updateFilters({}); + } + + // Re-search with cleared filters + if (inputValue && inputValue.length >= minLength) { + searchFor(inputValue, {}); + } }; + /** + * Handle click on a suggestion item + * + * @param {number} idx - Index of the clicked suggestion + */ const handleItemClick = (idx) => { + // Trigger WordPress hook before navigation if hooks available + if (typeof wp !== 'undefined' && wp.hooks) { + wp.hooks.doAction('ep.Autosuggest.onItemClick', suggestions[idx], idx, inputValue); + } + + // Navigate to the suggestion URL window.location.href = suggestions[idx].url; setShow(false); }; - const handleClose = () => setShow(false); - + /** + * Toggle expanded view (show all results) + * @function + * @description Switches between condensed and expanded view modes + * @returns {void} Updates the expanded state by toggling the previous value + */ const handleViewAll = () => setExpanded((prev) => !prev); - // Position the dropdown absolutely under the input - // eslint-disable-next-line @wordpress/no-unused-vars-before-return - const [dropdownStyle, setDropdownStyle] = useState({}); - useEffect(() => { - if (!inputEl || !show) return; - const rect = inputEl.getBoundingClientRect(); - setDropdownStyle({ - position: 'absolute', - top: rect.bottom + window.scrollY, - left: rect.left + window.scrollX, - width: rect.width, - zIndex: 100, - }); - }, [inputEl, show, inputValue]); - // Only show dropdown if show is true and there are suggestions if (!show || suggestions.length === 0) { return null; } + // Prepare the filtered suggestions to pass to the list template + const displayedSuggestions = expanded ? suggestions : suggestions.slice(0, perPage); + + // Assemble props for the suggestion list + const suggestionListProps = { + suggestions: displayedSuggestions, + activeIndex, + onItemClick: handleItemClick, + SuggestionItemTemplate, + showViewAll: suggestions.length > perPage, + onViewAll: handleViewAll, + expanded, + activeFilters, + onFilterChange: handleFilterChange, + onResetFilters: resetFilters, + }; + return ( -
    - - perPage} - onViewAll={handleViewAll} - expanded={expanded} - /> +
    +
    ); }; diff --git a/assets/js/autosuggest-v2/components/FilterTabs.js b/assets/js/autosuggest-v2/components/FilterTabs.js deleted file mode 100644 index a0884f3234..0000000000 --- a/assets/js/autosuggest-v2/components/FilterTabs.js +++ /dev/null @@ -1,43 +0,0 @@ -// Filter tabs component for content type filtering -const FilterTabs = ({ activeFilter, onFilterChange }) => { - // Filter UI (Articles, Videos, Discussions) - const filterTabs = [ - { label: window.epasI18n?.articles || 'Articles', value: 'article' }, - { label: window.epasI18n?.videos || 'Videos', value: 'video' }, - { - label: window.epasI18n?.discussions || 'Discussions', - value: 'discussion', - }, - ]; - - return ( -
    - {filterTabs.map((tab) => ( - - ))} - -
    - ); -}; - -export default FilterTabs; diff --git a/assets/js/autosuggest-v2/components/SuggestionItem.js b/assets/js/autosuggest-v2/components/SuggestionItem.js index 60f4fa4616..f4d62f6225 100644 --- a/assets/js/autosuggest-v2/components/SuggestionItem.js +++ b/assets/js/autosuggest-v2/components/SuggestionItem.js @@ -1,23 +1,42 @@ +import { applySuggestionItemFilter } from '../src/hooks'; + // Default Suggestion Item Template -const SuggestionItem = ({ suggestion, isActive, onClick }) => ( -
  • - - {suggestion.thumbnail && ( - - )} - {suggestion.title} - {suggestion.category && ( - {suggestion.category} - )} - -
  • -); +const SuggestionItem = ({ suggestion, isActive, onClick }) => { + // Apply filters to props + const filteredProps = applySuggestionItemFilter(suggestion, isActive, onClick); + + // Check if a custom renderer was provided through the filter + if (filteredProps.renderSuggestion) { + return filteredProps.renderSuggestion(); + } + + // Otherwise, use the default rendering + const { + suggestion: filteredSuggestion, + isActive: filteredIsActive, + onClick: filteredOnClick, + } = filteredProps; + + return ( +
  • + + {filteredSuggestion.thumbnail && ( + + )} + {filteredSuggestion.title} + {filteredSuggestion.category && ( + {filteredSuggestion.category} + )} + +
  • + ); +}; export default SuggestionItem; diff --git a/assets/js/autosuggest-v2/components/SuggestionList.js b/assets/js/autosuggest-v2/components/SuggestionList.js index 83f30ceaa0..315e5423b9 100644 --- a/assets/js/autosuggest-v2/components/SuggestionList.js +++ b/assets/js/autosuggest-v2/components/SuggestionList.js @@ -1,44 +1,50 @@ +import { applySuggestionListFilter } from '../src/hooks'; + // Default Suggestion List Template -const SuggestionList = ({ - suggestions, - activeIndex, - onItemClick, - onClose, - SuggestionItemTemplate, - showViewAll, - onViewAll, - expanded, -}) => ( -
    -
    - {window.epasI18n?.searchIn || 'Search in'} - +const SuggestionList = (props) => { + // Apply filters to props + const filteredProps = applySuggestionListFilter(props); + + // Check if a custom renderer was provided through the filter + if (filteredProps.renderSuggestionList) { + return filteredProps.renderSuggestionList(); + } + + // Otherwise, use the default rendering + const { + suggestions, + activeIndex, + onItemClick, + SuggestionItemTemplate, + showViewAll, + onViewAll, + expanded, + } = filteredProps; + + return ( +
    +
    + {window.epasI18n?.searchIn && {window.epasI18n?.searchIn}} +
    +
      + {suggestions.map((suggestion, idx) => ( + onItemClick(idx)} + /> + ))} +
    + {showViewAll && ( + + )}
    -
      - {suggestions.map((suggestion, idx) => ( - onItemClick(idx)} - /> - ))} -
    - {showViewAll && ( - - )} -
    -); + ); +}; export default SuggestionList; diff --git a/assets/js/autosuggest-v2/index.js b/assets/js/autosuggest-v2/index.js index aa51831ee8..b7f331c46f 100644 --- a/assets/js/autosuggest-v2/index.js +++ b/assets/js/autosuggest-v2/index.js @@ -3,164 +3,64 @@ import { ApiSearchProvider } from '../api-search'; import { apiEndpoint, apiHost, argsSchema, paramPrefix, requestIdBase } from './src/config'; import { AutosuggestContext } from './src/context'; import AutosuggestUI from './components/AutosuggestUI'; -import SuggestionItem from './components/SuggestionItem'; -import SuggestionList from './components/SuggestionList'; -/** - * Mounts an Autosuggest component on a search input element. - * - * @param {HTMLInputElement} input - The search input element to attach the dropdown to. - * @param {object} apiConfig - Configuration for the search API. - * @param {string} apiConfig.apiEndpoint - Path of the API endpoint. - * @param {string} apiConfig.apiHost - Hostname or base URL of the API. - * @param {object} apiConfig.argsSchema - Schema defining allowed query arguments. - * @param {string} apiConfig.paramPrefix - Prefix to use for query parameters. - * @param {string} apiConfig.requestIdBase - Base string for generating request IDs. - * @param {object} [contextValue={}] - Optional context value passed into the Autosuggest context. - * - * @example - * const input = document.querySelector('#search-input'); - * mountAutosuggestOnInput(input, { - * apiEndpoint: '/wp/v2/search', - * apiHost: 'https://example.com', - * argsSchema: { term: 'string', per_page: 'number' }, - * paramPrefix: 's', - * requestIdBase: 'autosuggest-' - * }); - */ function mountAutosuggestOnInput(input, apiConfig, contextValue = {}) { if (input.dataset.epAutosuggestMounted) return; input.dataset.epAutosuggestMounted = '1'; - const container = document.createElement('div'); - container.className = 'ep-autosuggest-dropdown-container'; - // Insert after the input - input.parentNode.insertBefore(container, input.nextSibling); + // wrap input + dropdown in a single container + const wrapper = document.createElement('div'); + wrapper.className = 'ep-autosuggest-wrapper'; + input.parentNode.replaceChild(wrapper, input); + wrapper.appendChild(input); - // Render the dropdown container - const render = () => { - createRoot(container).render( - - - - - , - ); - }; + const dropdownContainer = document.createElement('div'); + dropdownContainer.className = 'ep-autosuggest-dropdown-container'; + wrapper.appendChild(dropdownContainer); - // Only render on input/focus, not on page load - input.addEventListener('input', render); - input.addEventListener('focus', render); + // createRoot once + const root = createRoot(dropdownContainer); + root.render( + + + + + , + ); } -/** - * Initializes autosuggest on all matching search inputs and observes for dynamically added ones. - * - * @param {object} [apiConfig={}] - Configuration passed through to `mountAutosuggestOnInput`. - * @param {string} [apiConfig.apiEndpoint] - API endpoint path for search. - * @param {string} [apiConfig.apiHost] - Host or base URL for API requests. - * @param {object} [apiConfig.argsSchema] - Schema defining allowed query arguments. - * @param {string} [apiConfig.paramPrefix] - Prefix to use for query parameters. - * @param {string} [apiConfig.requestIdBase] - Base string for generating request IDs. - * @returns {MutationObserver} The observer instance used to watch for new search inputs. - * - * @example - * // Start autosuggest on current and future search fields - * const observer = initialize({ - * apiEndpoint: '/wp/v2/search', - * apiHost: 'https://example.com', - * argsSchema: { term: 'string' }, - * paramPrefix: 's', - * requestIdBase: 'autosuggest-', - * }); - * - * // Later, to stop observing: - * observer.disconnect(); - */ - function initialize(apiConfig = {}) { - // Find and mount on existing search inputs - document - .querySelectorAll('input[type="search"], .ep-autosuggest, .search-field') - .forEach((input) => { - if (input.tagName === 'INPUT' && input.type === 'search') { - mountAutosuggestOnInput(input, apiConfig); - } else if ( - input.classList && - (input.classList.contains('ep-autosuggest') || - input.classList.contains('search-field')) && - input.tagName === 'INPUT' - ) { - mountAutosuggestOnInput(input, apiConfig); - } - }); - - // Observe for dynamically added search fields - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - mutation.addedNodes.forEach((node) => { - if ( - node.nodeType === 1 && - node.tagName === 'INPUT' && - (node.type === 'search' || - node.classList.contains('ep-autosuggest') || - node.classList.contains('search-field')) - ) { - mountAutosuggestOnInput(node, apiConfig); - } - }); - }); - }); + const selector = 'input[type="search"], .ep-autosuggest, .search-field'; + const mountAll = (el) => { + if (el.tagName === 'INPUT') mountAutosuggestOnInput(el, apiConfig); + }; - observer.observe(document.body, { childList: true, subtree: true }); + document.querySelectorAll(selector).forEach(mountAll); - return observer; // Return for potential cleanup + const obs = new MutationObserver((ms) => { + ms.forEach((m) => + m.addedNodes.forEach((node) => { + if (node.matches?.(selector)) mountAll(node); + }), + ); + }); + obs.observe(document.body, { childList: true, subtree: true }); + return obs; } -// Initialize with default config from window globals -const apiConfig = { - apiEndpoint: window.epasApiEndpoint || '/api/v1/search/posts/my-index', - apiHost: window.epasApiHost || '', - argsSchema: window.epasArgsSchema || {}, - paramPrefix: window.epasParamPrefix || '', - requestIdBase: window.epasRequestIdBase || '', -}; - -// Auto-initialize if not in a module environment +// auto-init if (typeof window !== 'undefined') { - initialize(apiConfig); - - // Expose for testing or external use - window.EPAutosuggest = { - initialize, - AutosuggestUI, - AutosuggestContext, - mountAutosuggestOnInput, - SuggestionItem, - SuggestionList, + const cfg = { + apiEndpoint: window.epasApiEndpoint || apiEndpoint, + apiHost: window.epasApiHost || apiHost, + argsSchema: window.epasArgsSchema || argsSchema, + paramPrefix: window.epasParamPrefix || paramPrefix, + requestIdBase: window.epasRequestIdBase || requestIdBase, }; + initialize(cfg); + window.EPAutosuggest = { initialize, mountAutosuggestOnInput }; + document.dispatchEvent(new CustomEvent('ep_autosuggest_loaded')); } -export { - AutosuggestUI, - AutosuggestContext, - mountAutosuggestOnInput, - SuggestionItem, - SuggestionList, - initialize, -}; - -export default { - initialize, - mountAutosuggestOnInput, -}; +export { initialize, mountAutosuggestOnInput }; +export default { initialize, mountAutosuggestOnInput }; diff --git a/assets/js/autosuggest-v2/readme.md b/assets/js/autosuggest-v2/readme.md new file mode 100644 index 0000000000..fd861154c6 --- /dev/null +++ b/assets/js/autosuggest-v2/readme.md @@ -0,0 +1,235 @@ +There are hooks available for use to provide developer customization. You can insert the following hooks into a theme or plugin to customize the query, filter the results, override the markup, or trigger an action when a result is clicked. + + import './style.css'; + + /** + * Available hooks: + * - ep.Autosuggest.queryParams - Modify search query parameters + * - ep.Autosuggest.suggestions - Filter search results + * - ep.Autosuggest.suggestionItem - Customize individual suggestion item rendering + * - ep.Autosuggest.suggestionList - Customize suggestion list rendering + * - ep.Autosuggest.onItemClick - Triggered when a suggestion item is clicked + * + * Available Events: + * - ep_autosuggest_loaded - window.EPAutosuggest is available - safe to hook into ui overrides + */ + + /** + * Hook: ep.Autosuggest.queryParams + * Example: Only show "post" post_types in the search results. + * Status: Works + */ + const enableOnlyShowPosts = false; + if (enableOnlyShowPosts) { + wp.hooks.addFilter( + "ep.Autosuggest.queryParams", + "my-theme/filter-post-types", + function (params, searchTerm, filters) { + const newParams = new URLSearchParams(params.toString()); + newParams.set("post_type", "post"); + return newParams; + } + ); + } + + /** + * Hook: ep.Autosuggest.suggestions + * Example: Only show posts with thumbnails + * Status: Works + */ + const enableOnlyPostsWithThumbnails = false; + if (enableOnlyPostsWithThumbnails) { + wp.hooks.addFilter( + "ep.Autosuggest.suggestions", + "my-theme/only-with-thumbnails", + function (searchResults, searchTerm) { + return searchResults.filter( + (item) => + item._source.thumbnail && item._source.thumbnail !== "" + ); + } + ); + } + + /** + * Hook: ep.Autosuggest.suggestions + * Example: Add some extra data to the source object (could be output later with a suggestionItem hook) + * Status: Works + */ + const enableEnhanceResults = false; + if (enableEnhanceResults) { + wp.hooks.addFilter( + "ep.Autosuggest.suggestions", + "my-theme/enhance-results", + function (searchResults, searchTerm) { + return searchResults.map((item) => ({ + ...item, + _source: { + ...item._source, + relevanceScore: 100, + highlightedTitle: item._source.post_title, + }, + })); + } + ); + } + + /** + * Hook: ep.Autosuggest.onItemClick + * Example: Console log data on click (could be used for GA tracking) + * Status: Works + */ + const enableAddClickHook = false; + if (enableAddClickHook) { + wp.hooks.addAction( + "ep.Autosuggest.onItemClick", + "my-theme/track-clicks", + function (suggestion, index, searchTerm) { + console.log("on click:", suggestion, index, searchTerm); + } + ); + } + + /** + * Hook: ep.Autosuggest.suggestionItem + * Example: Custom UI Overrides + * Status: Works + */ + const registerCustomSuggestionItem = () => { + const CustomSuggestionItem = (props) => { + const { suggestion, isActive, onClick } = props; + return ( +
  • + +
    + {suggestion.thumbnail && ( + + )} +
    + Title Here!!! : {suggestion.title} + {suggestion.excerpt &&

    {suggestion.excerpt}

    } + {suggestion.type && ( + + {suggestion.type} + + )} +
    +
    +
    +
  • + ); + }; + wp.hooks.addFilter( + "ep.Autosuggest.suggestionItem", + "my-theme/custom-suggestion-item", + function (props, originalSuggestion) { + if (!originalSuggestion || !originalSuggestion._source) { + return props; + } + return { + ...props, + renderSuggestion: () => , + }; + }, + 5 + ); + }; + document.addEventListener("ep_autosuggest_loaded", function () { + registerCustomSuggestionItem(); + }); + + /** + * Hook: ep.Autosuggest.suggestionList + * Example: Custom UI Overrides + * Status: Works + */ + const registerCustomSuggestionList = () => { + const CustomSuggestionList = (props) => { + const { + suggestions, + activeIndex, + onItemClick, + SuggestionItemTemplate, + showViewAll, + onViewAll, + expanded, + } = props; + + // Group suggestions by type + const groupedSuggestions = {}; + suggestions.forEach((suggestion) => { + const type = suggestion.type || "other"; + if (!groupedSuggestions[type]) { + groupedSuggestions[type] = []; + } + groupedSuggestions[type].push(suggestion); + }); + + return ( +
    +
    +

    Search Results ({suggestions.length})

    +
    + {Object.entries(groupedSuggestions).map(([type, items]) => ( +
    +

    + {type.charAt(0).toUpperCase() + type.slice(1)}s +

    +
      + {items.map((suggestion, idx) => ( + + onItemClick( + suggestions.indexOf(suggestion) + ) + } + /> + ))} +
    +
    + ))} + {showViewAll && ( + + )} +
    + ); + }; + wp.hooks.addFilter( + "ep.Autosuggest.suggestionList", + "my-theme/custom-suggestion-list", + function (props) { + return { + ...props, + renderSuggestionList: () => , + }; + }, + 5 + ); + }; + document.addEventListener("ep_autosuggest_loaded", function () { + registerCustomSuggestionList(); + }); + diff --git a/assets/js/autosuggest-v2/src/hooks.js b/assets/js/autosuggest-v2/src/hooks.js new file mode 100644 index 0000000000..54bb3a9a53 --- /dev/null +++ b/assets/js/autosuggest-v2/src/hooks.js @@ -0,0 +1,70 @@ +/** + * WordPress hooks integration for Autosuggest component + */ + +/** + * Apply filters to search results + * + * @param {Array} searchResults - The original search results from API + * @param {string} searchTerm - The current search term + * @returns {Array} - The filtered search results + */ +export const applyResultsFilter = (searchResults, searchTerm) => { + // Check if wp.hooks is available (WordPress environment) + if (typeof wp !== 'undefined' && wp.hooks) { + return wp.hooks.applyFilters('ep.Autosuggest.suggestions', searchResults, searchTerm); + } + + return searchResults; +}; + +/** + * Apply filters to query parameters + * + * @param {object} params - The original query parameters + * @param {string} searchTerm - The current search term + * @param {object} filters - Any active filters + * @returns {object} - The modified query parameters + */ +export const applyQueryParamsFilter = (params, searchTerm, filters = {}) => { + // Check if wp.hooks is available (WordPress environment) + if (typeof wp !== 'undefined' && wp.hooks) { + return wp.hooks.applyFilters('ep.Autosuggest.queryParams', params, searchTerm, filters); + } + + return params; +}; + +/** + * Apply filters to a suggestion item before rendering + * + * @param {object} suggestion - The suggestion item data + * @param {boolean} isActive - Whether this item is currently active/focused + * @param {Function} onClick - Click handler for the item + * @returns {object} - The modified suggestion item props + */ +export const applySuggestionItemFilter = (suggestion, isActive, onClick) => { + if (typeof wp !== 'undefined' && wp.hooks) { + return wp.hooks.applyFilters( + 'ep.Autosuggest.suggestionItem', + { suggestion, isActive, onClick }, + suggestion, + ); + } + + return { suggestion, isActive, onClick }; +}; + +/** + * Apply filters to suggestion list props before rendering + * + * @param {object} listProps - The suggestion list props + * @returns {object} - The modified suggestion list props + */ +export const applySuggestionListFilter = (listProps) => { + if (typeof wp !== 'undefined' && wp.hooks) { + return wp.hooks.applyFilters('ep.Autosuggest.suggestionList', listProps); + } + + return listProps; +}; diff --git a/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php b/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php index 529b63e7ea..51d5855392 100644 --- a/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php +++ b/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php @@ -25,6 +25,20 @@ */ class AutosuggestV2 extends Feature { + /** + * Elasticsearch index name. + * + * @var string + */ + protected $index; + + /** + * Host URL. + * + * @var string + */ + protected $host; + /** * Autosuggest query generated by intercept_search_request * @@ -32,6 +46,27 @@ class AutosuggestV2 extends Feature { */ public $autosuggest_query = array(); + /** + * WooCommerce is in use. + * + * @var boolean + */ + protected $is_woocommerce; + + /** + * Elasticsearch query template. + * + * @var string + */ + protected $search_template = ''; + + /** + * Feature settings + * + * @var array + */ + protected $settings = []; + /** * Initialize feature setting it's config * @@ -44,7 +79,7 @@ public function __construct() { $this->index = Indexables::factory()->get( 'post' )->get_index_name(); - $this->requires_feature = 'instant-results'; + $this->requires_install_reindex = true; $this->is_woocommerce = function_exists( 'WC' ); @@ -78,7 +113,8 @@ public function set_i18n_strings(): void { $this->short_title = esc_html__( 'Autosuggest V2', 'elasticpress' ); - $this->summary = '

    ' . __( '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' ) . '

    '; + $this->summary = '

    ' . __( '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' ) . '

    '; $this->docs_url = __( 'https://www.elasticpress.io/documentation/article/configuring-elasticpress-via-the-plugin-dashboard/#autosuggest', 'elasticpress' ); } @@ -94,6 +130,212 @@ public function output_feature_box_long() { slug ) { + return; + } + + if ( true === $data['active'] ) { + $this->epio_save_search_template(); + } else { + $this->epio_delete_search_template(); + } + } + + /** + * Get the endpoint for the Instant Results search template. + * + * @return string Instant Results search template endpoint. + */ + public function get_template_endpoint() { + /** + * Filters the search template API endpoint. + * + * @since 4.0.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 ); + } + + /** + * Return true if a given feature is supported by Instant Results. + * + * 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 ); + } + + /** + * 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 Instant Results + * search template. + * + * By default Instant Results 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 4.1.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; + } + + /** + * 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 4.0.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 4.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 4.3.0 + * @hook ep_autosuggest_v2_template_deleted + * @param {string} $index Index name. + */ + do_action( 'ep_autosuggest_v2_template_deleted', $this->index ); + } + + /** + * 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 ); + } + /** * Setup feature functionality * @@ -108,6 +350,11 @@ public function setup() { add_filter( 'ep_saved_weighting_configuration', array( $this, 'epio_send_autosuggest_public_request' ) ); add_filter( 'wp', array( $this, 'epio_send_autosuggest_allowed' ) ); add_filter( 'ep_pre_sync_index', array( $this, 'epio_send_autosuggest_public_request' ) ); + + add_filter( 'ep_after_update_feature', [ $this, 'after_update_feature' ], 10, 3 ); + add_filter( 'ep_after_sync_index', [ $this, 'epio_save_search_template' ] ); + add_filter( 'ep_saved_weighting_configuration', [ $this, 'epio_save_search_template' ] ); + add_action( 'pre_get_posts', [ $this, 'maybe_apply_product_visibility' ] ); } /** @@ -458,7 +705,7 @@ public function enqueue_scripts() { * postType: which post types to use for suggestions * action: the action to take when selecting an item. Possible values are "search" and "navigate". */ - $api_endpoint = apply_filters( 'ep_instant_results_search_endpoint', "api/v1/search/posts/{$this->index}", $this->index ); + $api_endpoint = apply_filters( 'ep_autosuggest_v2_search_endpoint', "api/v1/search/posts/{$this->index}", $this->index ); wp_localize_script( 'elasticpress-autosuggest-v2', @@ -493,10 +740,10 @@ public function get_args_schema() { * The number of results per page for Instant Results. * * @since 4.5.0 - * @hook ep_instant_results_per_page + * @hook ep_autosuggest_v2_per_page * @param {int} $per_page Results per page. */ - $per_page = apply_filters( 'ep_instant_results_per_page', $this->settings['per_page'] ?? '3' ); + $per_page = apply_filters( 'ep_autosuggest_v2_per_page', $this->settings['per_page'] ?? '3' ); $args_schema = array( 'highlight' => array( @@ -555,10 +802,10 @@ public function get_args_schema() { * when adding or removing arguments. * * @since 4.5.1 - * @hook ep_instant_results_args_schema + * @hook ep_autosuggest_v2_args_schema * @param {array} $args_schema Results per page. */ - return apply_filters( 'ep_instant_results_args_schema', $args_schema ); + return apply_filters( 'ep_autosuggest_v2_args_schema', $args_schema ); } /** @@ -863,25 +1110,13 @@ public function apply_autosuggest_weighting( $config = array() ) { * * @param array $response Response * @param array $query ES Query + * @param array $args Request arguments + * @param int $failures Number of request failures * @return array $response Response */ - public function intercept_search_request( $response, $query = array() ) { - $this->autosuggest_query = $query['args']['body']; - - $message = wp_json_encode( - array( - esc_html__( 'This is a fake request to build the ElasticPress Autosuggest query. It is not really sent.', 'elasticpress' ), - ) - ); - - return array( - 'is_ep_fake_request' => true, - 'body' => $message, - 'response' => array( - 'code' => 200, - 'message' => $message, - ), - ); + public function intercept_search_request( $response, $query = [], $args = [], $failures = 0 ) { + $this->search_template = $query['args']['body']; + return wp_remote_request( $query['url'], $args ); } /** @@ -891,15 +1126,44 @@ public function intercept_search_request( $response, $query = array() ) { * @since 2.4 */ public function requirements_status() { - $status = new FeatureRequirementsStatus( 0 ); + $status = new FeatureRequirementsStatus( 2 ); - $status->message = array(); + $status->message = []; - $status->message[] = esc_html__( 'This feature modifies the site’s default user experience by presenting a list of suggestions below detected search fields as text is entered into the field.', 'elasticpress' ); + if ( Utils\is_epio() ) { + $status->code = 1; - if ( ! Utils\is_epio() ) { + /** + * 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 4.0.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[] = wp_kses_post( __( "You aren't using ElasticPress.io so we can't be sure your host is properly secured. Autosuggest requires a publicly accessible endpoint, which can expose private content and allow data modification if improperly configured.", 'elasticpress' ) ); + $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 Instant Results 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; From 852dd351f5b4dff49cda957fbf1c2c02b9aaadd8 Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Mon, 5 May 2025 23:27:11 -0500 Subject: [PATCH 03/20] Add support for non-url based api-searches --- assets/js/api-search/index.js | 56 ++++++++++++++++++++----------- assets/js/autosuggest-v2/index.js | 2 +- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/assets/js/api-search/index.js b/assets/js/api-search/index.js index 8285a39d62..73813f3854 100644 --- a/assets/js/api-search/index.js +++ b/assets/js/api-search/index.js @@ -79,6 +79,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 = ({ @@ -90,17 +91,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. @@ -118,8 +120,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. @@ -271,7 +273,7 @@ export const ApiSearchProvider = ({ * @returns {void} */ const pushState = useCallback(() => { - if (typeof paramPrefix === 'undefined') { + if (typeof paramPrefix === 'undefined' || !useUrlParams) { return; } @@ -292,7 +294,7 @@ export const ApiSearchProvider = ({ } else { window.history.replaceState(state, document.title, window.location.href); } - }, [argsSchema, paramPrefix]); + }, [argsSchema, paramPrefix, useUrlParams]); /** * Handle popstate event. @@ -301,7 +303,7 @@ export const ApiSearchProvider = ({ */ const onPopState = useCallback( (event) => { - if (typeof paramPrefix === 'undefined') { + if (typeof paramPrefix === 'undefined' || !useUrlParams) { return; } @@ -311,7 +313,7 @@ export const ApiSearchProvider = ({ popState(event.state); } }, - [paramPrefix], + [paramPrefix, useUrlParams], ); /** @@ -320,12 +322,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. @@ -336,7 +340,7 @@ export const ApiSearchProvider = ({ const handle = async () => { const { args, isOn, isPoppingState, activeFilters, searchTerm } = stateRef.current; - if (!isPoppingState) { + if (!isPoppingState && useUrlParams) { pushState(); } @@ -344,16 +348,27 @@ export const ApiSearchProvider = ({ return; } - // Get URL parameters from args - let urlParams = getUrlParamsFromArgs(args, argsSchema); + // Build URL parameters from args + const urlParams = new URLSearchParams(); + + // Add all args from schema to URL params + Object.entries(args).forEach(([key, value]) => { + if (value !== null && value !== undefined) { + if (Array.isArray(value)) { + urlParams.set(key, value.join(',')); + } else { + urlParams.set(key, value.toString()); + } + } + }); // Apply query parameter filters through WP hooks - urlParams = applyQueryParamsFilter(urlParams, searchTerm, activeFilters); + const filteredParams = applyQueryParamsFilter(urlParams, searchTerm, activeFilters); setIsLoading(true); try { - const response = await fetchResults(urlParams); + const response = await fetchResults(filteredParams); if (!response) { return; @@ -379,7 +394,7 @@ export const ApiSearchProvider = ({ }; handle(); - }, [argsSchema, fetchResults, pushState]); + }, [fetchResults, pushState, useUrlParams]); /** * Effects. @@ -434,6 +449,7 @@ export const ApiSearchProvider = ({ suggestedTerms, isFirstSearch, updateFilters, + useUrlParams, }; return {children}; diff --git a/assets/js/autosuggest-v2/index.js b/assets/js/autosuggest-v2/index.js index b7f331c46f..0134bf2201 100644 --- a/assets/js/autosuggest-v2/index.js +++ b/assets/js/autosuggest-v2/index.js @@ -21,7 +21,7 @@ function mountAutosuggestOnInput(input, apiConfig, contextValue = {}) { // createRoot once const root = createRoot(dropdownContainer); root.render( - + From 18724717913f60831322fe2364b502fde1ef8bba Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Mon, 5 May 2025 23:34:29 -0500 Subject: [PATCH 04/20] Add validation check for thumbnails --- assets/js/autosuggest-v2/components/AutosuggestUI.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/assets/js/autosuggest-v2/components/AutosuggestUI.js b/assets/js/autosuggest-v2/components/AutosuggestUI.js index 16afd798ac..c584e448b1 100644 --- a/assets/js/autosuggest-v2/components/AutosuggestUI.js +++ b/assets/js/autosuggest-v2/components/AutosuggestUI.js @@ -40,17 +40,14 @@ const AutosuggestUI = ({ // Map searchResults to suggestions, applying any active filters const suggestions = (searchResults || []) - // First apply type filter if active .filter((hit) => !activeTypeFilter || hit._source.post_type === activeTypeFilter) - // Then map to the format expected by suggestion components .map((hit) => ({ id: hit._source.ID, title: hit._source.post_title, url: hit._source.permalink, type: hit._source.post_type, - thumbnail: hit._source.thumbnail || null, + thumbnail: typeof hit._source.thumbnail === 'string' ? hit._source.thumbnail : null, category: hit._source.category || null, - // Include original source data for advanced filtering _source: hit._source, })); From b8379b11af7ae6c2625de50da5c61059871b3f9b Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Tue, 6 May 2025 00:42:11 -0500 Subject: [PATCH 05/20] Match styling to autosuggest v1 --- assets/css/autosuggest-v2.css | 51 +++++++---------------------------- 1 file changed, 9 insertions(+), 42 deletions(-) diff --git a/assets/css/autosuggest-v2.css b/assets/css/autosuggest-v2.css index 45bbb83fc5..52243769f8 100644 --- a/assets/css/autosuggest-v2.css +++ b/assets/css/autosuggest-v2.css @@ -1,31 +1,32 @@ @import "./global/colors.css"; -.ep-autosuggest-container { +.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); - display: none; - position: absolute; - width: 100%; z-index: 200; - & > ul { + & ul { list-style: none; margin: 0 !important; - & > li { + & li { font-family: sans-serif; - & > a.autosuggest-link { + & 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); @@ -42,40 +43,6 @@ } } -.ep-autosuggest { - background: #fff; - padding: 20px; - width: 100%; -} - -button.ep-autosuggest-filter { - padding: 5px 10px; -} - -.ep-autosuggest-header { - align-items: center; - display: flex; - justify-content: space-between; - width: 100%; -} - -button.ep-autosuggest-close { - align-items: center; - display: flex; - height: 30px; - justify-content: center; - padding: 0; - position: absolute; - right: 0; - text-transform: uppercase; - top: 0; - width: 30px; -} - -.ep-autosuggest-wrapper { - position: relative; -} - .ep-autosuggest-dropdown-container { position: absolute; width: 100%; From 17dbcf5c68dd77115cbd762c9173d301fd9bcbe2 Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Wed, 21 May 2025 17:37:52 -0500 Subject: [PATCH 06/20] Adjust group property to new standard --- includes/classes/Feature/AutosuggestV2/AutosuggestV2.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php b/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php index 51d5855392..320d6a65b7 100644 --- a/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php +++ b/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php @@ -75,6 +75,8 @@ class AutosuggestV2 extends Feature { public function __construct() { $this->slug = 'autosuggest-v2'; + $this->group = 'live-search'; + $this->host = trailingslashit( Utils\get_host() ); $this->index = Indexables::factory()->get( 'post' )->get_index_name(); @@ -109,8 +111,6 @@ public function __construct() { public function set_i18n_strings(): void { $this->title = esc_html__( 'Autosuggest V2', 'elasticpress' ); - $this->group = esc_html__( 'Live Search', 'elasticpress' ); - $this->short_title = esc_html__( 'Autosuggest V2', 'elasticpress' ); $this->summary = '

    ' . __( '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' ) . '

    ' . From c17ac4b52f44a667579fdee3419396fbfe9a0175 Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Wed, 21 May 2025 18:27:21 -0500 Subject: [PATCH 07/20] Small test adjustment --- tests/cypress/integration/features/interface.cy.js | 1 + 1 file changed, 1 insertion(+) 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 From da87bad2de0eebd10a2589e87cd8824bbf57e9ad Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Thu, 22 May 2025 14:22:14 -0500 Subject: [PATCH 08/20] Refactor, clean-up --- assets/js/api-search/index.js | 83 +-------- assets/js/api-search/src/reducer.js | 4 - .../components/AutosuggestUI.js | 157 +++--------------- .../components/SuggestionItem.js | 6 +- .../components/SuggestionList.js | 24 +-- assets/js/autosuggest-v2/{src => }/config.js | 0 .../{src/useAutosuggestInput.js => hooks.js} | 83 +++++++-- assets/js/autosuggest-v2/index.js | 103 ++++++++---- assets/js/autosuggest-v2/readme.md | 5 +- assets/js/autosuggest-v2/src/context.js | 7 - assets/js/autosuggest-v2/src/hooks.js | 70 -------- .../Feature/AutosuggestV2/AutosuggestV2.php | 4 +- 12 files changed, 178 insertions(+), 368 deletions(-) rename assets/js/autosuggest-v2/{src => }/config.js (100%) rename assets/js/autosuggest-v2/{src/useAutosuggestInput.js => hooks.js} (59%) delete mode 100644 assets/js/autosuggest-v2/src/context.js delete mode 100644 assets/js/autosuggest-v2/src/hooks.js diff --git a/assets/js/api-search/index.js b/assets/js/api-search/index.js index 73813f3854..92a892b8aa 100644 --- a/assets/js/api-search/index.js +++ b/assets/js/api-search/index.js @@ -25,42 +25,7 @@ import { getUrlWithParams, } from './src/utilities'; -/** - * Add WordPress hooks integration - */ - -/** - * Apply filters to search results - * - * @param {Array} searchResults - The original search results from API - * @param {string} searchTerm - The current search term - * @returns {Array} - The filtered search results - */ -export const applyResultsFilter = (searchResults, searchTerm) => { - // Check if wp.hooks is available (WordPress environment) - if (typeof wp !== 'undefined' && wp.hooks) { - return wp.hooks.applyFilters('ep.Autosuggest.suggestions', searchResults, searchTerm); - } - - return searchResults; -}; - -/** - * Apply filters to query parameters - * - * @param {object} params - The original query parameters - * @param {string} searchTerm - The current search term - * @param {object} filters - Any active filters - * @returns {object} - The modified query parameters - */ -export const applyQueryParamsFilter = (params, searchTerm, filters = {}) => { - // Check if wp.hooks is available (WordPress environment) - if (typeof wp !== 'undefined' && wp.hooks) { - return wp.hooks.applyFilters('ep.Autosuggest.queryParams', params, searchTerm, filters); - } - - return params; -}; +import { applyResultsFilter } from '../autosuggest-v2/hooks'; /** * Instant Results context. @@ -149,7 +114,6 @@ export const ApiSearchProvider = ({ suggestedTerms: [], isFirstSearch: true, searchTerm: '', - activeFilters: {}, }); /** @@ -189,16 +153,6 @@ export const ApiSearchProvider = ({ dispatch({ type: 'SEARCH', args }); }, []); - /** - * Update filters for the search - * - * @param {object} filters - Filter key-value pairs - * @returns {void} - */ - const updateFilters = useCallback((filters) => { - dispatch({ type: 'UPDATE_FILTERS', filters }); - }, []); - /** * Update the search term, triggering a search and resetting facet * constraints. @@ -207,9 +161,9 @@ export const ApiSearchProvider = ({ * @param {object} filters Optional filters to apply * @returns {void} */ - const searchFor = useCallback((searchTerm, filters = {}) => { - dispatch({ type: 'SEARCH_FOR', searchTerm, filters }); - }, []); + const searchFor = (searchTerm) => { + dispatch({ type: 'SEARCH_FOR', searchTerm }); + }; /** * Set loading state. @@ -338,7 +292,7 @@ export const ApiSearchProvider = ({ */ const handleSearch = useCallback(() => { const handle = async () => { - const { args, isOn, isPoppingState, activeFilters, searchTerm } = stateRef.current; + const { args, isOn, isPoppingState, searchTerm } = stateRef.current; if (!isPoppingState && useUrlParams) { pushState(); @@ -348,34 +302,19 @@ export const ApiSearchProvider = ({ return; } - // Build URL parameters from args - const urlParams = new URLSearchParams(); - - // Add all args from schema to URL params - Object.entries(args).forEach(([key, value]) => { - if (value !== null && value !== undefined) { - if (Array.isArray(value)) { - urlParams.set(key, value.join(',')); - } else { - urlParams.set(key, value.toString()); - } - } - }); - - // Apply query parameter filters through WP hooks - const filteredParams = applyQueryParamsFilter(urlParams, searchTerm, activeFilters); + const urlParams = getUrlParamsFromArgs(args, argsSchema); setIsLoading(true); try { - const response = await fetchResults(filteredParams); + const response = await fetchResults(urlParams); if (!response) { return; } // Apply filters to search results if hooks are available - if (response.hits && response.hits.hits) { + if (response.hits && response.hits.hits && !useUrlParams) { response.hits.hits = applyResultsFilter(response.hits.hits, searchTerm); } @@ -394,7 +333,7 @@ export const ApiSearchProvider = ({ }; handle(); - }, [fetchResults, pushState, useUrlParams]); + }, [argsSchema, fetchResults, pushState, useUrlParams]); /** * Effects. @@ -407,7 +346,6 @@ export const ApiSearchProvider = ({ state.args.order, state.args.offset, state.args.search, - state.activeFilters, ]); /** @@ -423,14 +361,12 @@ export const ApiSearchProvider = ({ totalResults, suggestedTerms, isFirstSearch, - activeFilters, } = stateRef.current; // eslint-disable-next-line react/jsx-no-constructed-context-values const contextValue = { aggregations, args, - activeFilters, clearConstraints, clearResults, getUrlParamsFromArgs, @@ -448,7 +384,6 @@ export const ApiSearchProvider = ({ turnOff, suggestedTerms, isFirstSearch, - updateFilters, useUrlParams, }; diff --git a/assets/js/api-search/src/reducer.js b/assets/js/api-search/src/reducer.js index 7a9e54e88f..20e924ba37 100644 --- a/assets/js/api-search/src/reducer.js +++ b/assets/js/api-search/src/reducer.js @@ -98,10 +98,6 @@ export default (state, action) => { break; } - case 'UPDATE_FILTERS': { - newState.activeFilters = { ...newState.activeFilters, ...action.filters }; - break; - } default: break; } diff --git a/assets/js/autosuggest-v2/components/AutosuggestUI.js b/assets/js/autosuggest-v2/components/AutosuggestUI.js index c584e448b1..37aa338c7e 100644 --- a/assets/js/autosuggest-v2/components/AutosuggestUI.js +++ b/assets/js/autosuggest-v2/components/AutosuggestUI.js @@ -1,58 +1,27 @@ -import { useContext, useState, useEffect } from 'react'; +import { useState } from 'react'; import { useApiSearch } from '../../api-search'; -import { AutosuggestContext } from '../src/context'; -import SuggestionItem from './SuggestionItem'; +import SuggestionItem from './SuggestionItem'; // Original SuggestionItem component import SuggestionList from './SuggestionList'; -import useAutosuggestInput from '../src/useAutosuggestInput'; +import { useKeyboardInput } from '../hooks'; -// Main Autosuggest UI component -const AutosuggestUI = ({ - inputEl, // DOM node of the input - minLength = 2, - perPage = 3, - // ...props -}) => { - // Use enhanced context with additional methods from API Search Provider - const { - searchResults, - searchFor, - activeFilters: contextFilters = {}, - updateFilters, - } = useApiSearch(); +const AutosuggestUI = ({ inputEl, minLength = 2 }) => { + const { searchResults, searchFor } = useApiSearch(); const [inputValue, setInputValue] = useState(''); const [activeIndex, setActiveIndex] = useState(-1); const [show, setShow] = useState(false); - const [expanded, setExpanded] = useState(false); - - // Local state for active filters, synced with context - const [activeFilters, setActiveFilters] = useState(contextFilters); - const [activeTypeFilter, setActiveTypeFilter] = useState(null); - - // Get customized templates from context - const { SuggestionItemTemplate = SuggestionItem, SuggestionListTemplate = SuggestionList } = - useContext(AutosuggestContext); - - // Sync local filters with context filters when they change - useEffect(() => { - setActiveFilters(contextFilters); - }, [contextFilters]); - // Map searchResults to suggestions, applying any active filters - const suggestions = (searchResults || []) - .filter((hit) => !activeTypeFilter || hit._source.post_type === activeTypeFilter) - .map((hit) => ({ - id: hit._source.ID, - title: hit._source.post_title, - url: hit._source.permalink, - type: hit._source.post_type, - thumbnail: typeof hit._source.thumbnail === 'string' ? hit._source.thumbnail : null, - category: hit._source.category || null, - _source: hit._source, - })); - - // Handle input events and keyboard navigation - useAutosuggestInput({ + const suggestions = (searchResults || []).map((hit) => ({ + id: hit._source.ID, + title: hit._source.post_title, + url: hit._source.permalink, + type: hit._source.post_type, + thumbnail: typeof hit._source.thumbnail === 'string' ? hit._source.thumbnail : null, + category: hit._source.category || null, + _source: hit._source, + })); + + useKeyboardInput({ inputEl, minLength, show, @@ -62,113 +31,27 @@ const AutosuggestUI = ({ suggestions, activeIndex, setActiveIndex, - // Pass search function that includes filters - searchFor: (term) => searchFor(term, activeFilters), + searchFor, }); - /** - * Update a specific filter and trigger search - * - * @param {string} filterKey - The filter key to update - * @param {any} filterValue - The filter value - */ - const handleFilterChange = (filterKey, filterValue) => { - // Update local state - const newFilters = { - ...activeFilters, - [filterKey]: filterValue, - }; - - // Update local state - setActiveFilters(newFilters); - - // If this is a type filter, also update the UI state - if (filterKey === 'post_type') { - setActiveTypeFilter(filterValue); - } - - // If expanded, collapse the view - if (expanded) { - setExpanded(false); - } - - // Update context state and trigger search - if (typeof updateFilters === 'function') { - updateFilters(newFilters); - } - - // If we have a search term, trigger a new search with the updated filters - if (inputValue && inputValue.length >= minLength) { - searchFor(inputValue, newFilters); - } - }; - - /** - * Reset all filters to default values - */ - const resetFilters = () => { - setActiveFilters({}); - setActiveTypeFilter(null); - - if (typeof updateFilters === 'function') { - updateFilters({}); - } - - // Re-search with cleared filters - if (inputValue && inputValue.length >= minLength) { - searchFor(inputValue, {}); - } - }; - - /** - * Handle click on a suggestion item - * - * @param {number} idx - Index of the clicked suggestion - */ const handleItemClick = (idx) => { - // Trigger WordPress hook before navigation if hooks available if (typeof wp !== 'undefined' && wp.hooks) { wp.hooks.doAction('ep.Autosuggest.onItemClick', suggestions[idx], idx, inputValue); } - - // Navigate to the suggestion URL window.location.href = suggestions[idx].url; setShow(false); }; - /** - * Toggle expanded view (show all results) - * @function - * @description Switches between condensed and expanded view modes - * @returns {void} Updates the expanded state by toggling the previous value - */ - const handleViewAll = () => setExpanded((prev) => !prev); - - // Only show dropdown if show is true and there are suggestions - if (!show || suggestions.length === 0) { - return null; - } - - // Prepare the filtered suggestions to pass to the list template - const displayedSuggestions = expanded ? suggestions : suggestions.slice(0, perPage); - - // Assemble props for the suggestion list const suggestionListProps = { - suggestions: displayedSuggestions, + suggestions, activeIndex, onItemClick: handleItemClick, - SuggestionItemTemplate, - showViewAll: suggestions.length > perPage, - onViewAll: handleViewAll, - expanded, - activeFilters, - onFilterChange: handleFilterChange, - onResetFilters: resetFilters, + SuggestionItemTemplate: SuggestionItem, }; return (
    - + {show && suggestions.length > 0 && }
    ); }; diff --git a/assets/js/autosuggest-v2/components/SuggestionItem.js b/assets/js/autosuggest-v2/components/SuggestionItem.js index f4d62f6225..087e45e20d 100644 --- a/assets/js/autosuggest-v2/components/SuggestionItem.js +++ b/assets/js/autosuggest-v2/components/SuggestionItem.js @@ -1,16 +1,12 @@ -import { applySuggestionItemFilter } from '../src/hooks'; +import { applySuggestionItemFilter } from '../hooks'; -// Default Suggestion Item Template const SuggestionItem = ({ suggestion, isActive, onClick }) => { - // Apply filters to props const filteredProps = applySuggestionItemFilter(suggestion, isActive, onClick); - // Check if a custom renderer was provided through the filter if (filteredProps.renderSuggestion) { return filteredProps.renderSuggestion(); } - // Otherwise, use the default rendering const { suggestion: filteredSuggestion, isActive: filteredIsActive, diff --git a/assets/js/autosuggest-v2/components/SuggestionList.js b/assets/js/autosuggest-v2/components/SuggestionList.js index 315e5423b9..db02d3b6cd 100644 --- a/assets/js/autosuggest-v2/components/SuggestionList.js +++ b/assets/js/autosuggest-v2/components/SuggestionList.js @@ -1,25 +1,12 @@ -import { applySuggestionListFilter } from '../src/hooks'; +import { applySuggestionListFilter } from '../hooks'; -// Default Suggestion List Template const SuggestionList = (props) => { - // Apply filters to props const filteredProps = applySuggestionListFilter(props); - - // Check if a custom renderer was provided through the filter if (filteredProps.renderSuggestionList) { return filteredProps.renderSuggestionList(); } - // Otherwise, use the default rendering - const { - suggestions, - activeIndex, - onItemClick, - SuggestionItemTemplate, - showViewAll, - onViewAll, - expanded, - } = filteredProps; + const { suggestions, activeIndex, onItemClick, SuggestionItemTemplate } = filteredProps; return (
    @@ -36,13 +23,6 @@ const SuggestionList = (props) => { /> ))} - {showViewAll && ( - - )}
    ); }; diff --git a/assets/js/autosuggest-v2/src/config.js b/assets/js/autosuggest-v2/config.js similarity index 100% rename from assets/js/autosuggest-v2/src/config.js rename to assets/js/autosuggest-v2/config.js diff --git a/assets/js/autosuggest-v2/src/useAutosuggestInput.js b/assets/js/autosuggest-v2/hooks.js similarity index 59% rename from assets/js/autosuggest-v2/src/useAutosuggestInput.js rename to assets/js/autosuggest-v2/hooks.js index f4a77eb7fb..6aa98f0ec0 100644 --- a/assets/js/autosuggest-v2/src/useAutosuggestInput.js +++ b/assets/js/autosuggest-v2/hooks.js @@ -1,4 +1,68 @@ import { useEffect } from 'react'; +/** + * WordPress hooks integration for Autosuggest component + */ + +/** + * Apply filters to search results + * + * @param {Array} searchResults - The original search results from API + * @param {string} searchTerm - The current search term + * @returns {Array} - The filtered search results + */ +export const applyResultsFilter = (searchResults, searchTerm) => { + if (typeof wp !== 'undefined' && wp.hooks) { + return wp.hooks.applyFilters('ep.Autosuggest.suggestions', searchResults, searchTerm); + } + return searchResults; +}; + +/** + * Apply filters to query parameters + * + * @param {object} params - The original query parameters + * @param {string} searchTerm - The current search term + * @param {object} filters - Any active filters + * @returns {object} - The modified query parameters + */ +export const applyQueryParamsFilter = (params, searchTerm, filters = {}) => { + if (typeof wp !== 'undefined' && wp.hooks) { + return wp.hooks.applyFilters('ep.Autosuggest.queryParams', params, searchTerm, filters); + } + return params; +}; + +/** + * Apply filters to a suggestion item before rendering + * + * @param {object} suggestion - The suggestion item data + * @param {boolean} isActive - Whether this item is currently active/focused + * @param {Function} onClick - Click handler for the item + * @returns {object} - The modified suggestion item props + */ +export const applySuggestionItemFilter = (suggestion, isActive, onClick) => { + if (typeof wp !== 'undefined' && wp.hooks) { + return wp.hooks.applyFilters( + 'ep.Autosuggest.suggestionItem', + { suggestion, isActive, onClick }, + suggestion, + ); + } + return { suggestion, isActive, onClick }; +}; + +/** + * Apply filters to suggestion list props before rendering + * + * @param {object} listProps - The suggestion list props + * @returns {object} - The modified suggestion list props + */ +export const applySuggestionListFilter = (listProps) => { + if (typeof wp !== 'undefined' && wp.hooks) { + return wp.hooks.applyFilters('ep.Autosuggest.suggestionList', listProps); + } + return listProps; +}; /** * Attaches autosuggest behavior and keyboard navigation to an input element. @@ -16,21 +80,8 @@ import { useEffect } from 'react'; * @param {function(string):void} options.searchFor - Function to perform the search given input. * @returns {void} * - * @example - * useAutosuggestInput({ - * inputEl, - * minLength: 3, - * show, - * setShow, - * inputValue, - * setInputValue, - * suggestions, - * activeIndex, - * setActiveIndex, - * searchFor, - * }); */ -const useAutosuggestInput = ({ +export const useKeyboardInput = ({ inputEl, minLength, show, @@ -42,10 +93,8 @@ const useAutosuggestInput = ({ setActiveIndex, searchFor, }) => { - // Attach handlers to the input DOM node useEffect(() => { if (!inputEl) { - // no listeners to clean up return () => {}; } @@ -115,5 +164,3 @@ const useAutosuggestInput = ({ } }, [inputEl, inputValue, setInputValue]); }; - -export default useAutosuggestInput; diff --git a/assets/js/autosuggest-v2/index.js b/assets/js/autosuggest-v2/index.js index 0134bf2201..f882c93982 100644 --- a/assets/js/autosuggest-v2/index.js +++ b/assets/js/autosuggest-v2/index.js @@ -1,64 +1,111 @@ import { createRoot } from '@wordpress/element'; import { ApiSearchProvider } from '../api-search'; -import { apiEndpoint, apiHost, argsSchema, paramPrefix, requestIdBase } from './src/config'; -import { AutosuggestContext } from './src/context'; +import { apiEndpoint, apiHost, argsSchema, paramPrefix, requestIdBase } from './config'; import AutosuggestUI from './components/AutosuggestUI'; -function mountAutosuggestOnInput(input, apiConfig, contextValue = {}) { - if (input.dataset.epAutosuggestMounted) return; - input.dataset.epAutosuggestMounted = '1'; +/** + * Mounts the Autosuggest component onto a given input element. + * + * @param {HTMLInputElement} inputElement - The input element to attach the Autosuggest to. + * @param {object} apiConfig - Configuration for the API search. + */ +function mountAutosuggestOnInput(inputElement, apiConfig) { + // Prevent re-mounting on the same element + if (inputElement.dataset.epAutosuggestMounted) { + return; + } + inputElement.dataset.epAutosuggestMounted = 'true'; - // wrap input + dropdown in a single container + // Create a wrapper for the input and the dropdown const wrapper = document.createElement('div'); wrapper.className = 'ep-autosuggest-wrapper'; - input.parentNode.replaceChild(wrapper, input); - wrapper.appendChild(input); + if (inputElement.parentNode) { + inputElement.parentNode.replaceChild(wrapper, inputElement); + } + wrapper.appendChild(inputElement); + // Create a container for the suggestions dropdown const dropdownContainer = document.createElement('div'); dropdownContainer.className = 'ep-autosuggest-dropdown-container'; wrapper.appendChild(dropdownContainer); - // createRoot once + // Render the AutosuggestUI component const root = createRoot(dropdownContainer); root.render( - - - + , ); } -function initialize(apiConfig = {}) { - const selector = 'input[type="search"], .ep-autosuggest, .search-field'; - const mountAll = (el) => { - if (el.tagName === 'INPUT') mountAutosuggestOnInput(el, apiConfig); +/** + * Initializes the Autosuggest component on all designated input fields. + * + * @param {object} apiConfigOverrides - Optional overrides for the default API configuration. + * @returns {MutationObserver} The MutationObserver instance watching for new elements. + */ +function initialize(apiConfigOverrides = {}) { + const defaultConfig = { + apiEndpoint: window.epasApiEndpoint || apiEndpoint, + apiHost: window.epasApiHost || apiHost, + argsSchema: window.epasArgsSchema || argsSchema, + paramPrefix: window.epasParamPrefix || paramPrefix, + requestIdBase: window.epasRequestIdBase || requestIdBase, }; - document.querySelectorAll(selector).forEach(mountAll); + const finalApiConfig = { ...defaultConfig, ...apiConfigOverrides }; + + const autosuggestSelector = 'input[type="search"], .ep-autosuggest, .search-field'; - const obs = new MutationObserver((ms) => { - ms.forEach((m) => - m.addedNodes.forEach((node) => { - if (node.matches?.(selector)) mountAll(node); - }), - ); + // Function to mount Autosuggest on a single element + const mountOnElement = (element) => { + if (element.matches(autosuggestSelector) && element.tagName === 'INPUT') { + mountAutosuggestOnInput(element, finalApiConfig); + } + }; + + // Mount on existing elements + document.querySelectorAll(autosuggestSelector).forEach(mountOnElement); + + // Observe for dynamically added elements + const observer = new MutationObserver((mutationsList) => { + for (const mutation of mutationsList) { + if (mutation.type === 'childList') { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + // Check if the node itself matches + if (node.matches(autosuggestSelector)) { + mountOnElement(node); + } + // Check if any children of the node match (for complex DOM additions) + node.querySelectorAll(autosuggestSelector).forEach(mountOnElement); + } + }); + } + } }); - obs.observe(document.body, { childList: true, subtree: true }); - return obs; + + observer.observe(document.body, { childList: true, subtree: true }); + + return observer; } -// auto-init +// --- Main Execution --- if (typeof window !== 'undefined') { - const cfg = { + // Initialize Autosuggest with configurations from the window object or defaults. + const initialApiConfig = { apiEndpoint: window.epasApiEndpoint || apiEndpoint, apiHost: window.epasApiHost || apiHost, argsSchema: window.epasArgsSchema || argsSchema, paramPrefix: window.epasParamPrefix || paramPrefix, requestIdBase: window.epasRequestIdBase || requestIdBase, }; - initialize(cfg); + initialize(initialApiConfig); // + + // Expose public API window.EPAutosuggest = { initialize, mountAutosuggestOnInput }; + + // Dispatch a custom event to indicate that Autosuggest is loaded document.dispatchEvent(new CustomEvent('ep_autosuggest_loaded')); } diff --git a/assets/js/autosuggest-v2/readme.md b/assets/js/autosuggest-v2/readme.md index fd861154c6..4ca6fd6bcd 100644 --- a/assets/js/autosuggest-v2/readme.md +++ b/assets/js/autosuggest-v2/readme.md @@ -3,12 +3,15 @@ There are hooks available for use to provide developer customization. You can in import './style.css'; /** - * Available hooks: + * Available JS hooks: * - ep.Autosuggest.queryParams - Modify search query parameters * - ep.Autosuggest.suggestions - Filter search results * - ep.Autosuggest.suggestionItem - Customize individual suggestion item rendering * - ep.Autosuggest.suggestionList - Customize suggestion list rendering * - ep.Autosuggest.onItemClick - Triggered when a suggestion item is clicked + * + * Available PHP hooks: + * - ep_autosuggest_v2_per_page - Change the number of results returned * * Available Events: * - ep_autosuggest_loaded - window.EPAutosuggest is available - safe to hook into ui overrides diff --git a/assets/js/autosuggest-v2/src/context.js b/assets/js/autosuggest-v2/src/context.js deleted file mode 100644 index 5ffa92e007..0000000000 --- a/assets/js/autosuggest-v2/src/context.js +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -// Context for extensibility - allows custom templates for suggestions -export const AutosuggestContext = React.createContext({ - SuggestionItemTemplate: null, - SuggestionListTemplate: null, -}); diff --git a/assets/js/autosuggest-v2/src/hooks.js b/assets/js/autosuggest-v2/src/hooks.js deleted file mode 100644 index 54bb3a9a53..0000000000 --- a/assets/js/autosuggest-v2/src/hooks.js +++ /dev/null @@ -1,70 +0,0 @@ -/** - * WordPress hooks integration for Autosuggest component - */ - -/** - * Apply filters to search results - * - * @param {Array} searchResults - The original search results from API - * @param {string} searchTerm - The current search term - * @returns {Array} - The filtered search results - */ -export const applyResultsFilter = (searchResults, searchTerm) => { - // Check if wp.hooks is available (WordPress environment) - if (typeof wp !== 'undefined' && wp.hooks) { - return wp.hooks.applyFilters('ep.Autosuggest.suggestions', searchResults, searchTerm); - } - - return searchResults; -}; - -/** - * Apply filters to query parameters - * - * @param {object} params - The original query parameters - * @param {string} searchTerm - The current search term - * @param {object} filters - Any active filters - * @returns {object} - The modified query parameters - */ -export const applyQueryParamsFilter = (params, searchTerm, filters = {}) => { - // Check if wp.hooks is available (WordPress environment) - if (typeof wp !== 'undefined' && wp.hooks) { - return wp.hooks.applyFilters('ep.Autosuggest.queryParams', params, searchTerm, filters); - } - - return params; -}; - -/** - * Apply filters to a suggestion item before rendering - * - * @param {object} suggestion - The suggestion item data - * @param {boolean} isActive - Whether this item is currently active/focused - * @param {Function} onClick - Click handler for the item - * @returns {object} - The modified suggestion item props - */ -export const applySuggestionItemFilter = (suggestion, isActive, onClick) => { - if (typeof wp !== 'undefined' && wp.hooks) { - return wp.hooks.applyFilters( - 'ep.Autosuggest.suggestionItem', - { suggestion, isActive, onClick }, - suggestion, - ); - } - - return { suggestion, isActive, onClick }; -}; - -/** - * Apply filters to suggestion list props before rendering - * - * @param {object} listProps - The suggestion list props - * @returns {object} - The modified suggestion list props - */ -export const applySuggestionListFilter = (listProps) => { - if (typeof wp !== 'undefined' && wp.hooks) { - return wp.hooks.applyFilters('ep.Autosuggest.suggestionList', listProps); - } - - return listProps; -}; diff --git a/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php b/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php index 320d6a65b7..6717ac6ffb 100644 --- a/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php +++ b/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php @@ -737,9 +737,9 @@ public function enqueue_scripts() { */ public function get_args_schema() { /** - * The number of results per page for Instant Results. + * The number of results per page for AutosuggestV2. * - * @since 4.5.0 + * @since 5.3.0 * @hook ep_autosuggest_v2_per_page * @param {int} $per_page Results per page. */ From af26d2ec7222dae7751aad67acce60ddb46d189a Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Thu, 22 May 2025 14:59:03 -0500 Subject: [PATCH 09/20] Update hook documentation --- assets/js/autosuggest-v2/readme.md | 523 ++++++++++++++++------------- 1 file changed, 296 insertions(+), 227 deletions(-) diff --git a/assets/js/autosuggest-v2/readme.md b/assets/js/autosuggest-v2/readme.md index 4ca6fd6bcd..c4b978d00f 100644 --- a/assets/js/autosuggest-v2/readme.md +++ b/assets/js/autosuggest-v2/readme.md @@ -1,238 +1,307 @@ -There are hooks available for use to provide developer customization. You can insert the following hooks into a theme or plugin to customize the query, filter the results, override the markup, or trigger an action when a result is clicked. +# ElasticPress Autosuggest V2 - Hooks Documentation - import './style.css'; - - /** - * Available JS hooks: - * - ep.Autosuggest.queryParams - Modify search query parameters - * - ep.Autosuggest.suggestions - Filter search results - * - ep.Autosuggest.suggestionItem - Customize individual suggestion item rendering - * - ep.Autosuggest.suggestionList - Customize suggestion list rendering - * - ep.Autosuggest.onItemClick - Triggered when a suggestion item is clicked - * - * Available PHP hooks: - * - ep_autosuggest_v2_per_page - Change the number of results returned - * - * Available Events: - * - ep_autosuggest_loaded - window.EPAutosuggest is available - safe to hook into ui overrides - */ - - /** - * Hook: ep.Autosuggest.queryParams - * Example: Only show "post" post_types in the search results. - * Status: Works - */ - const enableOnlyShowPosts = false; - if (enableOnlyShowPosts) { - wp.hooks.addFilter( - "ep.Autosuggest.queryParams", - "my-theme/filter-post-types", - function (params, searchTerm, filters) { - const newParams = new URLSearchParams(params.toString()); - newParams.set("post_type", "post"); - return newParams; - } - ); +This document outlines the available hooks and filters for customizing the ElasticPress Autosuggest V2 feature. + +## PHP Hooks + +### `ep_autosuggest_v2_per_page` + +**Type**: Filter +**Description**: Modifies the number of suggestions displayed per page in autosuggest results. + +**Parameters**: + +- `$default_per_page_value` (int): The default number of results per page + +**Example**: + +```php +add_filter( 'ep_autosuggest_v2_per_page', function( $default_per_page_value ) { + return 8; // Show 8 suggestions instead of default +} ); + +``` + +---------- + +## JavaScript Hooks + +### `ep.Autosuggest.queryParams` + +**Type**: Filter +**Description**: Modifies the query parameters sent to ElasticSearch before the search is executed. + +**Parameters**: + +- `params` (URLSearchParams): The original query parameters +- `searchTerm` (string): The current search term +- `filters` (object): Any applied filters + +**Returns**: URLSearchParams object with modified parameters + +**Example**: + +```javascript +wp.hooks.addFilter( + 'ep.Autosuggest.queryParams', + 'my-theme/filter-search-by-author', + function (params, searchTerm, filters) { + const newParams = new URLSearchParams(params.toString()); + + // Add author filter if search term includes "by:admin" + if (searchTerm.toLowerCase().includes('by:admin')) { + newParams.set('author_name', 'admin'); + } + + // Add custom parameter + newParams.set('custom_param', 'custom_value'); + + return newParams; } - - /** - * Hook: ep.Autosuggest.suggestions - * Example: Only show posts with thumbnails - * Status: Works - */ - const enableOnlyPostsWithThumbnails = false; - if (enableOnlyPostsWithThumbnails) { - wp.hooks.addFilter( - "ep.Autosuggest.suggestions", - "my-theme/only-with-thumbnails", - function (searchResults, searchTerm) { - return searchResults.filter( - (item) => - item._source.thumbnail && item._source.thumbnail !== "" - ); - } +); + +``` + +### `ep.Autosuggest.suggestions` + +**Type**: Filter +**Description**: Modifies the search results/suggestions after they are retrieved but before they are rendered. + +**Parameters**: + +- `searchResults` (array): Array of search result objects +- `searchTerm` (string): The current search term + +**Returns**: Modified array of search results + +**Example**: + +```javascript +wp.hooks.addFilter( + 'ep.Autosuggest.suggestions', + 'my-theme/prioritize-pages', + function (searchResults, searchTerm) { + if (!Array.isArray(searchResults)) { + return searchResults; + } + + // Separate pages from other content types + const pages = searchResults.filter(item => + item._source && item._source.post_type === 'page' ); - } - - /** - * Hook: ep.Autosuggest.suggestions - * Example: Add some extra data to the source object (could be output later with a suggestionItem hook) - * Status: Works - */ - const enableEnhanceResults = false; - if (enableEnhanceResults) { - wp.hooks.addFilter( - "ep.Autosuggest.suggestions", - "my-theme/enhance-results", - function (searchResults, searchTerm) { - return searchResults.map((item) => ({ - ...item, - _source: { - ...item._source, - relevanceScore: 100, - highlightedTitle: item._source.post_title, - }, - })); - } + const others = searchResults.filter(item => + !item._source || item._source.post_type !== 'page' ); + + // Prioritize pages and add custom flags + const prioritizedResults = [...pages, ...others]; + + return prioritizedResults.map(item => ({ + ...item, + _source: { + ...item._source, + customFlag: item._source.post_type === 'page' + ? 'Priority Content' + : 'Standard Content', + }, + })); } +); + +``` + +### `ep.Autosuggest.suggestionItem` + +**Type**: Filter +**Description**: Customizes the rendering of individual suggestion items in the dropdown. + +**Parameters**: + +- `props` (object): Contains suggestion data, isActive state, and onClick handler +- `originalSuggestion` (object): The original suggestion object + +**Returns**: Modified props object with custom renderSuggestion function + +**Example**: + +```javascript +const MyCustomSuggestionItem = (props) => { + const { suggestion, isActive, onClick } = props; + const itemClasses = `my-custom-item ${isActive ? 'my-active-item' : ''}`; - /** - * Hook: ep.Autosuggest.onItemClick - * Example: Console log data on click (could be used for GA tracking) - * Status: Works - */ - const enableAddClickHook = false; - if (enableAddClickHook) { - wp.hooks.addAction( - "ep.Autosuggest.onItemClick", - "my-theme/track-clicks", - function (suggestion, index, searchTerm) { - console.log("on click:", suggestion, index, searchTerm); - } - ); + return ( +
  • + + {suggestion.thumbnail && ( + + )} +
    + {suggestion.title} + {suggestion.category && ( + + Category: {suggestion.category} + + )} + Type: {suggestion.type} + {suggestion._source.customFlag && ( +

    + {suggestion._source.customFlag} +

    + )} +
    +
    +
  • + ); +}; + +wp.hooks.addFilter( + 'ep.Autosuggest.suggestionItem', + 'my-theme/custom-suggestion-item-renderer', + function (props, originalSuggestion) { + return { + ...props, + renderSuggestion: () => , + }; } - - /** - * Hook: ep.Autosuggest.suggestionItem - * Example: Custom UI Overrides - * Status: Works - */ - const registerCustomSuggestionItem = () => { - const CustomSuggestionItem = (props) => { - const { suggestion, isActive, onClick } = props; - return ( -
  • - -
    - {suggestion.thumbnail && ( - { + const { + suggestions, + activeIndex, + onItemClick, + SuggestionItemTemplate, + showViewAll, + onViewAll, + expanded, + } = props; + + if (!suggestions.length) { + return null; + } + + // Group suggestions by type + const groupedSuggestions = suggestions.reduce((acc, suggestion) => { + const type = suggestion.type || 'other'; + if (!acc[type]) { + acc[type] = []; + } + acc[type].push(suggestion); + return acc; + }, {}); + + return ( +
  • - ); - }; - wp.hooks.addFilter( - "ep.Autosuggest.suggestionItem", - "my-theme/custom-suggestion-item", - function (props, originalSuggestion) { - if (!originalSuggestion || !originalSuggestion._source) { - return props; - } - return { - ...props, - renderSuggestion: () => , - }; - }, - 5 - ); - }; - document.addEventListener("ep_autosuggest_loaded", function () { - registerCustomSuggestionItem(); - }); - - /** - * Hook: ep.Autosuggest.suggestionList - * Example: Custom UI Overrides - * Status: Works - */ - const registerCustomSuggestionList = () => { - const CustomSuggestionList = (props) => { - const { - suggestions, - activeIndex, - onItemClick, - SuggestionItemTemplate, - showViewAll, - onViewAll, - expanded, - } = props; - - // Group suggestions by type - const groupedSuggestions = {}; - suggestions.forEach((suggestion) => { - const type = suggestion.type || "other"; - if (!groupedSuggestions[type]) { - groupedSuggestions[type] = []; - } - groupedSuggestions[type].push(suggestion); - }); - - return ( -
    -
    -

    Search Results ({suggestions.length})

    -
    - {Object.entries(groupedSuggestions).map(([type, items]) => ( -
    -

    - {type.charAt(0).toUpperCase() + type.slice(1)}s -

    -
      - {items.map((suggestion, idx) => ( - - onItemClick( - suggestions.indexOf(suggestion) - ) - } - /> - ))} -
    -
    - ))} - {showViewAll && ( - - )} + ); + })} +
    - ); + ))} + {showViewAll && ( + + )} +
    + ); +}; + +wp.hooks.addFilter( + 'ep.Autosuggest.suggestionList', + 'my-theme/custom-suggestion-list-renderer', + function (listProps) { + return { + ...listProps, + renderSuggestionList: () => , }; - wp.hooks.addFilter( - "ep.Autosuggest.suggestionList", - "my-theme/custom-suggestion-list", - function (props) { - return { - ...props, - renderSuggestionList: () => , - }; - }, - 5 - ); - }; - document.addEventListener("ep_autosuggest_loaded", function () { - registerCustomSuggestionList(); - }); + } +); + +``` + +### `ep.Autosuggest.onItemClick` + +**Type**: Action Hook +**Description**: Fires when a user clicks on a suggestion item. Useful for analytics and tracking. + +**Parameters**: + +- `suggestion` (object): The clicked suggestion object +- `index` (number): The index of the clicked suggestion +- `inputValue` (string): The current search term + +**Example**: + +```javascript +wp.hooks.addAction( + 'ep.Autosuggest.onItemClick', + 'my-theme/track-suggestion-clicks', + function (suggestion, index, inputValue) { + console.log('Suggestion Clicked (Action Hook):', { + title: suggestion.title, + url: suggestion.url, + type: suggestion.type, + index: index, + searchTerm: inputValue, + }); + + // Send to analytics service + // gtag('event', 'autosuggest_click', { ... }); + } +); + +``` + +## Event Listener + +The customizations should be registered when the ElasticPress Autosuggest V2 is fully loaded: + +```javascript +document.addEventListener('ep_autosuggest_loaded', registerAutosuggestCustomizations); + +``` +This ensures that all the necessary components are available before attempting to modify them. From 1c9ee5f711541d1f067b4eaaa4b106e795b1ac6d Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Thu, 22 May 2025 15:17:55 -0500 Subject: [PATCH 10/20] Minor linting fixes --- assets/js/api-search/index.js | 1 - package.json | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/assets/js/api-search/index.js b/assets/js/api-search/index.js index 92a892b8aa..d3b8039d04 100644 --- a/assets/js/api-search/index.js +++ b/assets/js/api-search/index.js @@ -158,7 +158,6 @@ export const ApiSearchProvider = ({ * constraints. * * @param {string} searchTerm Search term. - * @param {object} filters Optional filters to apply * @returns {void} */ const searchFor = (searchTerm) => { diff --git a/package.json b/package.json index bc6739a431..f74c1ee080 100644 --- a/package.json +++ b/package.json @@ -67,7 +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", + "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", @@ -97,7 +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", + "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", From 80fd0ef8cd59193916601f1f3d5e228e17587386 Mon Sep 17 00:00:00 2001 From: Zach Date: Thu, 29 May 2025 00:11:44 -0500 Subject: [PATCH 11/20] Remove unused function --- .../classes/Feature/AutosuggestV2/AutosuggestV2.php | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php b/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php index 6717ac6ffb..26f7239b88 100644 --- a/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php +++ b/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php @@ -1451,19 +1451,6 @@ protected function set_settings_schema() { } } - /** - * DEPRECATED. Delete the cached query for autosuggest. - * - * @since 3.5.5 - */ - public function delete_cached_query() { - _doing_it_wrong( - __METHOD__, - esc_html__( 'This method should not be called anymore, as autosuggest requests are not sent regularly anymore.' ), - 'ElasticPress 4.7.0' - ); - } - /** * Get the contexts for autosuggest. * From 00fbcd55c5f8105c85d4f706f0719dbe46804cd5 Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Thu, 29 May 2025 01:28:58 -0500 Subject: [PATCH 12/20] Refactor to only whats needed --- .../Feature/AutosuggestV2/AutosuggestV2.php | 1471 ++++------------- 1 file changed, 321 insertions(+), 1150 deletions(-) diff --git a/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php b/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php index 6717ac6ffb..38c2079219 100644 --- a/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php +++ b/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php @@ -1,8 +1,6 @@ slug = 'autosuggest-v2'; @@ -81,20 +73,19 @@ public function __construct() { $this->index = Indexables::factory()->get( 'post' )->get_index_name(); - $this->requires_install_reindex = true; - $this->is_woocommerce = function_exists( 'WC' ); - $this->requires_install_reindex = true; - - $this->default_settings = array( - 'endpoint_url' => '', - 'autosuggest_selector' => '', - 'trigger_ga_event' => '0', - ); + $this->default_settings = [ + 'match_type' => 'all', + 'term_count' => '1', + 'per_page' => get_option( 'posts_per_page', 6 ), + 'search_behavior' => '0', + ]; $this->settings = $this->get_settings(); + $this->requires_install_reindex = true; + $this->available_during_installation = true; $this->is_powered_by_epio = Utils\is_epio(); @@ -106,7 +97,7 @@ public function __construct() { * Sets i18n strings. * * @return void - * @since 5.2.0 + * @since 5.3.0 */ public function set_i18n_strings(): void { $this->title = esc_html__( 'Autosuggest V2', 'elasticpress' ); @@ -115,24 +106,131 @@ public function set_i18n_strings(): void { $this->summary = '

    ' . __( '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/' + ) + ); + } - $this->docs_url = __( 'https://www.elasticpress.io/documentation/article/configuring-elasticpress-via-the-plugin-dashboard/#autosuggest', 'elasticpress' ); + return $status; } /** - * Output feature box long + * Setup feature functionality. * - * @since 2.4 + * @return void */ - public function output_feature_box_long() { - ?> -

    - 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 Instant Results feature is being activated or deactivated. + * the AutosuggestV2 feature is being activated or deactivated. * * @param string $feature Feature slug * @param array $settings Feature settings @@ -140,7 +238,7 @@ public function output_feature_box_long() { * * @return void * - * @since 4.3.0 + * @since 5.3.0 */ public function after_update_feature( $feature, $settings, $data ) { if ( $feature !== $this->slug ) { @@ -155,15 +253,15 @@ public function after_update_feature( $feature, $settings, $data ) { } /** - * Get the endpoint for the Instant Results search template. + * Get the endpoint for the AutosuggestV2 search template. * - * @return string Instant Results search template endpoint. + * @return string AutosuggestV2 search template endpoint. */ public function get_template_endpoint() { /** * Filters the search template API endpoint. * - * @since 4.0.0 + * @since 5.3.0 * @hook ep_autosuggest_v2_template_endpoint * @param {string} $endpoint Endpoint path. * @param {string} $index Elasticsearch index. @@ -173,30 +271,80 @@ public function get_template_endpoint() { } /** - * Return true if a given feature is supported by Instant Results. + * Save the search template to ElasticPress.io. * - * 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. + * @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. * - * @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. + * @return void + * + * @since 5.3.0 */ - public function is_integrated_request( $is_integrated, $context ) { - $supported_contexts = [ - 'autosuggest', - 'documents', - 'search', - 'weighting', - 'woocommerce', - ]; + public function epio_delete_search_template() { + $endpoint = $this->get_template_endpoint(); - return in_array( $context, $supported_contexts, true ); + 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; } /** @@ -219,16 +367,16 @@ public function get_search_template() { ); /** - * The ID of the current user when generating the Instant Results + * The ID of the current user when generating the AutosuggestV2 * search template. * - * By default Instant Results sets the current user as anomnymous when + * 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 4.1.0 + * @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. @@ -262,60 +410,59 @@ public function get_search_template() { } /** - * Save the search template to ElasticPress.io. + * Return true if a given feature is supported by AutosuggestV2. * - * @return void + * 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 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', - ] - ); + public function is_integrated_request( $is_integrated, $context ) { + $supported_contexts = [ + 'autosuggest', + 'documents', + 'search', + 'weighting', + 'woocommerce', + ]; - /** - * Fires after the request is sent the search template API endpoint. - * - * @since 4.0.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 ); + return in_array( $context, $supported_contexts, true ); } /** - * Delete the search template from ElasticPress.io. - * - * @return void + * Store intercepted request body and return request result. * - * @since 4.3.0 + * @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 epio_delete_search_template() { - $endpoint = $this->get_template_endpoint(); + public function intercept_search_request( $response, $query = [], $args = [], $failures = 0 ) { + $this->search_template = $query['args']['body']; - Elasticsearch::factory()->remote_request( - $endpoint, - [ - 'blocking' => false, - 'method' => 'DELETE', - ] - ); + return wp_remote_request( $query['url'], $args ); + } - /** - * Fires after the request is sent the search template API endpoint. - * - * @since 4.3.0 - * @hook ep_autosuggest_v2_template_deleted - * @param {string} $index Index name. - */ - do_action( 'ep_autosuggest_v2_template_deleted', $this->index ); + /** + * 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; } /** @@ -337,397 +484,93 @@ public function maybe_apply_product_visibility( $query ) { } /** - * Setup feature functionality + * Apply product visibility taxonomy query. * - * @since 2.4 - */ - public function setup() { - add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); - add_filter( 'ep_post_mapping', array( $this, 'mapping' ) ); - add_filter( 'ep_post_sync_args', array( $this, 'filter_term_suggest' ), 10 ); - add_filter( 'ep_post_fuzziness_arg', array( $this, 'set_fuzziness' ), 10, 3 ); - add_filter( 'ep_weighted_query_for_post_type', array( $this, 'adjust_fuzzy_fields' ), 10, 3 ); - add_filter( 'ep_saved_weighting_configuration', array( $this, 'epio_send_autosuggest_public_request' ) ); - add_filter( 'wp', array( $this, 'epio_send_autosuggest_allowed' ) ); - add_filter( 'ep_pre_sync_index', array( $this, 'epio_send_autosuggest_public_request' ) ); - - add_filter( 'ep_after_update_feature', [ $this, 'after_update_feature' ], 10, 3 ); - add_filter( 'ep_after_sync_index', [ $this, 'epio_save_search_template' ] ); - add_filter( 'ep_saved_weighting_configuration', [ $this, 'epio_save_search_template' ] ); - add_action( 'pre_get_posts', [ $this, 'maybe_apply_product_visibility' ] ); - } - - /** - * Display decaying settings on dashboard. + * 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. * - * @since 2.4 - */ - public function output_feature_box_settings() { - $settings = $this->get_settings(); - ?> -
    -
    -
    - -

    -
    -
    - -
    -
    -
    -
    - -

    -
    -
    - epio_allowed_parameters(); - return; - } - - $endpoint_url = ( defined( 'EP_AUTOSUGGEST_ENDPOINT' ) && EP_AUTOSUGGEST_ENDPOINT ) ? EP_AUTOSUGGEST_ENDPOINT : $settings['endpoint_url']; - ?> - -
    -
    -
    - value="" type="text" name="settings[endpoint_url]" id="feature_autosuggest_endpoint_url"> - - -

    - - -

    -
    -
    - - get( 'post' ); - - $mapping = $post_indexable->add_ngram_analyzer( $mapping ); - $mapping = $post_indexable->add_term_suggest_field( $mapping ); + 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']; - // Note the assignment by reference below. - if ( version_compare( (string) Elasticsearch::factory()->get_elasticsearch_version(), '7.0', '<' ) ) { - $mapping_properties = &$mapping['mappings']['post']['properties']; - } else { - $mapping_properties = &$mapping['mappings']['properties']; + if ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) { + $product_visibility_not_in[] = $product_visibility_terms['outofstock']; } - $text_type = $mapping_properties['post_content']['type']; - - $mapping_properties['post_title']['fields']['suggest'] = array( - 'type' => $text_type, - 'analyzer' => 'edge_ngram_analyzer', - 'search_analyzer' => 'standard', - ); + if ( ! empty( $product_visibility_not_in ) ) { + $tax_query = $query->get( 'tax_query', array() ); - return $mapping; - } + $tax_query[] = array( + 'taxonomy' => 'product_visibility', + 'field' => 'term_taxonomy_id', + 'terms' => $product_visibility_not_in, + 'operator' => 'NOT IN', + ); - /** - * Ensure both search and autosuggest use fuziness with type auto - * - * @param integer $fuzziness Fuzziness - * @param array $search_fields Search Fields - * @param array $args Array of ES args - * @return array - */ - public function set_fuzziness( $fuzziness, $search_fields, $args ) { - if ( Utils\is_integrated_request( $this->slug, $this->get_contexts() ) && ! empty( $args['s'] ) ) { - return 'auto'; + $query->set( 'tax_query', $tax_query ); } - return $fuzziness; } /** - * Handle ngram search fields for fuzziness fields + * Add additional fields to post mapping. * - * @param array $query ES Query arguments - * @param string $post_type Post Type - * @param array $args WP_Query args - * @return array $query adjusted ES Query arguments + * @param array $mapping Post mapping. + * @return array Post mapping. */ - public function adjust_fuzzy_fields( $query, $post_type, $args ) { - if ( ! Utils\is_integrated_request( $this->slug, $this->get_contexts() ) || empty( $args['s'] ) ) { - return $query; - } + public function add_mapping_properties( $mapping ) { + $elasticsearch_version = Elasticsearch::factory()->get_elasticsearch_version(); - if ( ! isset( $query['bool'] ) || ! isset( $query['bool']['must'] ) ) { - return $query; - } - - /** - * Filter autosuggest ngram fields - * - * @hook ep_autosuggest_ngram_fields - * @param {array} $fields Fields available to ngram - * @return {array} New fields array - */ - $ngram_fields = apply_filters( - 'ep_autosuggest_ngram_fields', - array( - 'post_title' => 'post_title.suggest', - 'terms\.(.+)\.name' => 'term_suggest', - ) + $properties = array( + 'post_content_plain' => array( 'type' => 'text' ), ); - /** - * At this point, `$query` might look like this (using the 3.5 search algorithm): - * - * [ - * [bool] => [ - * [must] => [ - * [0] => [ - * [bool] => [ - * [should] => [ - * [0] => [ - * [multi_match] => [ - * [query] => ep_autosuggest_placeholder - * [type] => phrase - * [fields] => [ - * [0] => post_title^1 - * ... - * [n] => terms.category.name^27 - * ] - * [boost] => 3 - * ] - * ] - * [1] => [ - * [multi_match] => [ - * [query] => ep_autosuggest_placeholder - * [fields] => [ ... ] - * [type] => phrase - * [slop] => 5 - * ] - * ] - * ] - * ] - * ] - * ] - * ] - * ... - * ] - * - * Also, note the usage of `&$must_query`. This means that by changing `$must_query` - * you will be actually changing `$query`. - */ - foreach ( $query['bool']['must'] as &$must_query ) { - if ( ! isset( $must_query['bool'] ) || ! isset( $must_query['bool']['should'] ) ) { - continue; - } - foreach ( $must_query['bool']['should'] as &$current_bool_should ) { - if ( ! isset( $current_bool_should['multi_match'] ) || ! isset( $current_bool_should['multi_match']['fields'] ) ) { - continue; - } - - /** - * `fuzziness` is used in the original algorithm. - * `slop` is used in `3.5`. - * - * @see \ElasticPress\Indexable\Post\Post::format_args() - */ - if ( empty( $current_bool_should['multi_match']['fuzziness'] ) && empty( $current_bool_should['multi_match']['slop'] ) ) { - continue; - } - - $fields_to_add = array(); - - /** - * If the regex used in `$ngram_fields` matches more than one field, - * like taxonomies, for example, we use the min value - 1. - */ - foreach ( $current_bool_should['multi_match']['fields'] as $field ) { - foreach ( $ngram_fields as $regex => $ngram_field ) { - if ( preg_match( '/^(' . $regex . ')(\^(\d+))?$/', $field, $match ) ) { - $weight = 1; - if ( isset( $match[4] ) && $match[4] > 1 ) { - $weight = $match[4] - 1; - } - - if ( isset( $fields_to_add[ $ngram_field ] ) ) { - $fields_to_add[ $ngram_field ] = min( $fields_to_add[ $ngram_field ], $weight ); - } else { - $fields_to_add[ $ngram_field ] = $weight; - } - } - } - } - - foreach ( $fields_to_add as $field => $weight ) { - $current_bool_should['multi_match']['fields'][] = "{$field}^{$weight}"; - } - } + 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 $query; + return $mapping; } /** - * Add term suggestions to be indexed + * Add data for additional mapping properties. * - * @param array $post_args Array of ES args. - * @since 2.4 - * @return array + * @param array $post_args Post arguments. + * @param integer $post_id Post ID. + * @return array Post sync args. */ - public function filter_term_suggest( $post_args ) { - $suggest = array(); - - if ( ! empty( $post_args['terms'] ) ) { - foreach ( $post_args['terms'] as $taxonomy ) { - foreach ( $taxonomy as $term ) { - $suggest[] = $term['name']; - } - } - } + public function add_post_sync_args( $post_args, $post_id ) { + $post = get_post( $post_id ); - if ( ! empty( $suggest ) ) { - $post_args['term_suggest'] = $suggest; - } + $post_args['post_content_plain'] = $this->prepare_plain_content_arg( $post ); return $post_args; } + /** - * Enqueue our autosuggest script + * Get data for the plain post content. * - * @since 2.4 + * @param WP_Post $post Post object. + * @return string Post content. */ - public function enqueue_scripts() { - if ( Utils\is_indexing() ) { - return; - } - - $host = Utils\get_host(); - $settings = $this->get_settings(); - - if ( defined( 'EP_AUTOSUGGEST_ENDPOINT' ) && EP_AUTOSUGGEST_ENDPOINT ) { - $endpoint_url = EP_AUTOSUGGEST_ENDPOINT; - } elseif ( Utils\is_epio() ) { - $endpoint_url = trailingslashit( $host ) . Indexables::factory()->get( 'post' )->get_index_name() . '/autosuggest'; - } else { - $endpoint_url = $settings['endpoint_url']; - } - - if ( empty( $endpoint_url ) ) { - return; - } - - 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' ); - - wp_enqueue_style( - 'elasticpress-autosuggest-v2', - EP_URL . 'dist/css/autosuggest-v2-styles.css', - Utils\get_asset_info( 'autosuggest-styles', 'dependencies' ), - Utils\get_asset_info( 'autosuggest-styles', 'version' ) - ); - - /** Features Class @var Features $features */ - $features = Features::factory(); - - /** Search Feature @var Feature\Search\Search $search */ - $search = $features->get_registered_feature( 'search' ); - - $query = $this->generate_search_query(); - - $epas_options = array( - 'query' => $query['body'], - 'placeholder' => $query['placeholder'], - 'endpointUrl' => esc_url( untrailingslashit( $endpoint_url ) ), - 'selector' => empty( $settings['autosuggest_selector'] ) ? 'ep-autosuggest' : esc_html( $settings['autosuggest_selector'] ), - /** - * Filter autosuggest default selectors. - * - * @hook ep_autosuggest_default_selectors - * @since 3.6.0 - * @param {string} $selectors Default selectors used to attach autosuggest. - * @return {string} Selectors used to attach autosuggest. - */ - 'defaultSelectors' => apply_filters( 'ep_autosuggest_default_selectors', '.ep-autosuggest, input[type="search"], .search-field' ), - 'action' => 'navigate', - 'mimeTypes' => array(), - /** - * Filter autosuggest HTTP headers - * - * @hook ep_autosuggest_http_headers - * @param {array} $headers Autosuggest HTTP headers in name => value format - * @return {array} HTTP headers - */ - 'http_headers' => apply_filters( 'ep_autosuggest_http_headers', array() ), - 'triggerAnalytics' => ! empty( $settings['trigger_ga_event'] ), - 'addSearchTermHeader' => false, - 'requestIdBase' => Utils\get_request_id_base(), - ); - - if ( Utils\is_epio() ) { - $epas_options['addSearchTermHeader'] = true; - } - - $search_settings = $search->get_settings(); - - if ( ! $search_settings ) { - $search_settings = array(); - } - - $search_settings = wp_parse_args( $search_settings, $search->default_settings ); + public function prepare_plain_content_arg( $post ) { + $post_content = apply_filters( 'the_content', $post->post_content ); - if ( ! empty( $search_settings ) && $search_settings['highlight_enabled'] ) { - $epas_options['highlightingEnabled'] = true; - $epas_options['highlightingTag'] = apply_filters( 'ep_highlighting_tag', $search_settings['highlight_tag'] ); - $epas_options['highlightingClass'] = apply_filters( 'ep_highlighting_class', 'ep-highlight' ); - } - - /** - * Output variables to use in Javascript - * index: the Elasticsearch index name - * endpointUrl: the Elasticsearch autosuggest endpoint url - * postType: which post types to use for suggestions - * action: the action to take when selecting an item. Possible values are "search" and "navigate". - */ - $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, - 'facets' => $this->get_facets_for_frontend(), - 'highlightTag' => $this->settings['highlight_tag'] ?? false, - 'isWooCommerce' => $this->is_woocommerce, - 'locale' => str_replace( '_', '-', get_locale() ), - 'matchType' => $this->settings['match_type'] ?? false, - 'paramPrefix' => 'ep-', - 'postTypeLabels' => $this->get_post_type_labels(), - 'termCount' => $this->settings['term_count'] ?? false, - 'requestIdBase' => Utils\get_request_id_base(), - 'showSuggestions' => \ElasticPress\Features::factory()->get_registered_feature( 'did-you-mean' )->is_active(), - 'suggestionsBehavior' => $this->settings['search_behavior'] ?? false, - ) - ); + return wp_strip_all_tags( $post_content ); } /** @@ -743,14 +586,9 @@ public function get_args_schema() { * @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'] ?? '3' ); + $per_page = apply_filters( 'ep_autosuggest_v2_per_page', $this->settings['per_page'] ); $args_schema = array( - 'highlight' => array( - 'type' => 'string', - 'default' => $this->settings['highlight_tag'] ?? false, - 'allowedValues' => array( $this->settings['highlight_tag'] ?? false ), - ), 'offset' => array( 'type' => 'number', 'default' => 0, @@ -758,16 +596,16 @@ public function get_args_schema() { 'orderby' => array( 'type' => 'string', 'default' => 'relevance', - 'allowedValues' => array( 'date', 'price', 'relevance' ), + 'allowedValues' => [ 'date', 'price', 'relevance' ], ), 'order' => array( 'type' => 'string', 'default' => 'desc', - 'allowedValues' => array( 'asc', 'desc' ), + 'allowedValues' => [ 'asc', 'desc' ], ), 'per_page' => array( 'type' => 'number', - 'default' => absint( $per_page ?? 3 ), + 'default' => absint( $per_page ), ), 'post_type' => array( 'type' => 'strings', @@ -778,707 +616,40 @@ public function get_args_schema() { ), 'relation' => array( 'type' => 'string', - 'default' => 'all' === ( $this->settings['match_type'] ?? false ) ? 'and' : 'or', - 'allowedValues' => array( 'and', 'or' ), + 'default' => 'all' === $this->settings['match_type'] ? 'and' : 'or', + 'allowedValues' => [ 'and', 'or' ], ), ); - $selected_facets = explode( ',', $this->settings['facets'] ?? '' ); - $available_facets = $this->get_facets(); - - foreach ( $selected_facets as $key ) { - if ( isset( $available_facets[ $key ] ) ) { - $args_schema = array_merge( $args_schema, $available_facets[ $key ]['args'] ); - } - } - /** - * The schema defining the API arguments used by Instant Results. + * The schema defining the API arguments used by AutosuggestV2. * * The argument schema is used to configure the APISearchProvider - * component used by Instant Results, and should conform to what is - * supported by the API being used. The Instant Results UI expects + * 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 4.5.1 + * @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 ); } - /** - * Get facet configuration for the front end. - * - * @return Array Facet configuration for the front end. - */ - public function get_facets_for_frontend() { - $selected_facets = explode( ',', $this->settings['facets'] ?? '' ); - $available_facets = $this->get_facets(); - - $facets = array(); - - foreach ( $selected_facets as $key ) { - if ( isset( $available_facets[ $key ] ) ) { - $facet = $available_facets[ $key ]; - - $facets[] = array( - 'name' => $key, - 'label' => $facet['labels']['frontend'], - 'type' => $facet['type'], - 'postTypes' => $facet['post_types'], - ); - } - } - - return $facets; - } - - /** - * Get available facets. - * - * @return array Available facets. - */ - public function get_facets() { - $facets = array(); - - /** - * Post type facet. - */ - $facets['post_type'] = array( - 'type' => 'post_type', - 'post_types' => array(), - 'labels' => array( - 'admin' => __( 'Post type', 'elasticpress' ), - 'frontend' => __( 'Type', 'elasticpress' ), - ), - 'aggs' => array( - 'post_type' => array( - 'terms' => array( - 'field' => 'post_type.raw', - ), - ), - ), - /** - * The post_type arg needs to be supported regardless of whether - * the Post Type facet is present to be able to support setting the - * post type from the search form. - * - * @see ElasticPress\Feature\InstantResults::get_args_schema() - */ - 'args' => array(), - ); - - /** - * Taxonomy facets. - */ - $taxonomies = get_taxonomies( array( 'public' => true ), 'object' ); - $taxonomies = apply_filters( 'ep_facet_include_taxonomies', $taxonomies ); - - foreach ( $taxonomies as $slug => $taxonomy ) { - $name = 'tax-' . $slug; - $labels = get_taxonomy_labels( $taxonomy ); - - $admin_label = sprintf( - /* translators: $1$s: Taxonomy name. %2$s: Taxonomy slug. */ - esc_html__( '%1$s (%2$s)' ), - $labels->singular_name, - $slug - ); - - $post_types = Features::factory()->get_registered_feature( 'search' )->get_searchable_post_types(); - $post_types = array_intersect( $post_types, $taxonomy->object_type ); - $post_types = array_values( $post_types ); - - $facets[ $name ] = array( - 'type' => 'taxonomy', - 'post_types' => $post_types, - 'labels' => array( - 'admin' => $admin_label, - 'frontend' => $labels->singular_name, - ), - 'aggs' => array( - $name => array( - 'terms' => array( - 'field' => 'terms.' . $slug . '.facet', - 'size' => apply_filters( 'ep_facet_taxonomies_size', 10000, $taxonomy ), - ), - ), - ), - 'args' => array( - $name => array( - 'type' => 'strings', - ), - ), - ); - } - - /** - * Price facet. - */ - if ( $this->is_woocommerce ) { - $facets['price_range'] = array( - 'type' => 'price_range', - 'post_types' => array( 'product' ), - 'labels' => array( - 'admin' => __( 'Price range', 'elasticpress' ), - 'frontend' => __( 'Price', 'elasticpress' ), - ), - 'aggs' => array( - 'max_price' => array( - 'max' => array( - 'field' => 'meta._price.double', - ), - ), - 'min_price' => array( - 'min' => array( - 'field' => 'meta._price.double', - ), - ), - ), - 'args' => array( - 'max_price' => array( - 'type' => 'number', - ), - 'min_price' => array( - 'type' => 'number', - ), - ), - ); - } - - return $facets; - } - - /** - * Get post type labels. - * - * Only the post type slug is indexed, so we'll need the labels on the - * front end for display. - * - * @return array Array of post types and their labels. - */ - public function get_post_type_labels() { - $labels = array(); - - $post_types = Features::factory()->get_registered_feature( 'search' )->get_searchable_post_types(); - - foreach ( $post_types as $post_type ) { - $post_type_object = get_post_type_object( $post_type ); - $post_type_labels = get_post_type_labels( $post_type_object ); - - $labels[ $post_type ] = array( - 'plural' => $post_type_labels->name, - 'singular' => $post_type_labels->singular_name, - ); - } - - return $labels; - } - - /** - * Build a default search request to pass to the autosuggest javascript. - * The request will include a placeholder that can then be replaced. - * - * @return array Generated ElasticSearch request array( 'placeholder'=> placeholderstring, 'body' => request body ) - */ - public function generate_search_query() { - - /** - * Filter autosuggest query placeholder - * - * @hook ep_autosuggest_query_placeholder - * @param {string} $placeholder Autosuggest placeholder to be replaced later - * @return {string} New placeholder - */ - $placeholder = apply_filters( 'ep_autosuggest_query_placeholder', 'ep_autosuggest_placeholder' ); - - /** Features Class @var Features $features */ - $features = Features::factory(); - - $post_type = $features->get_registered_feature( 'search' )->get_searchable_post_types(); - - /** - * Filter post types available to autosuggest - * - * @hook ep_term_suggest_post_type - * @param {array} $post_types Post types - * @return {array} New post types - */ - $post_type = apply_filters( 'ep_term_suggest_post_type', array_values( $post_type ) ); - - $post_status = get_post_stati( - array( - 'public' => true, - 'exclude_from_search' => false, - ) - ); - - /** - * Filter post statuses available to autosuggest - * - * @hook ep_term_suggest_post_status - * @param {array} $post_statuses Post statuses - * @return {array} New post statuses - */ - $post_status = apply_filters( 'ep_term_suggest_post_status', array_values( $post_status ) ); - - add_filter( 'ep_intercept_remote_request', array( $this, 'intercept_remote_request' ) ); - add_filter( 'ep_weighting_configuration', array( $features->get_registered_feature( $this->slug ), 'apply_autosuggest_weighting' ) ); - - add_filter( 'ep_do_intercept_request', array( $features->get_registered_feature( $this->slug ), 'intercept_search_request' ), 10, 2 ); - - add_filter( 'posts_pre_query', array( $features->get_registered_feature( $this->slug ), 'return_empty_posts' ), 100, 1 ); // after ES Query to ensure we are not falling back to DB in any case - - new \WP_Query( - /** - * Filter WP Query args of the autosuggest query template. - * - * If you want to display 20 posts in autosuggest: - * - * ``` - * add_filter( - * 'ep_autosuggest_query_args', - * function( $args ) { - * $args['posts_per_page'] = 20; - * return $args; - * } - * ); - * ``` - * - * @since 4.4.0 - * @hook ep_autosuggest_query_args - * @param {array} $args Query args - * @return {array} New query args - */ - apply_filters( - 'ep_autosuggest_query_args', - array( - 'post_type' => $post_type, - 'post_status' => $post_status, - 's' => $placeholder, - 'ep_integrate' => true, - ) - ) - ); - - remove_filter( 'posts_pre_query', array( $features->get_registered_feature( $this->slug ), 'return_empty_posts' ), 100 ); - - remove_filter( 'ep_do_intercept_request', array( $features->get_registered_feature( $this->slug ), 'intercept_search_request' ) ); - - remove_filter( 'ep_weighting_configuration', array( $features->get_registered_feature( $this->slug ), 'apply_autosuggest_weighting' ) ); - - remove_filter( 'ep_intercept_remote_request', array( $this, 'intercept_remote_request' ) ); - - return array( - 'body' => $this->autosuggest_query, - 'placeholder' => $placeholder, - ); - } - - /** - * Ensure we do not fallback to WPDB query for this request - * - * @param array $posts array of post objects - * @return array $posts - */ - public function return_empty_posts( $posts = array() ) { - return array(); - } - - /** - * Allow applying custom weighting configuration for autosuggest - * - * @param array $config current configuration - * @return array $config desired configuration - */ - public function apply_autosuggest_weighting( $config = array() ) { - /** - * Filter autosuggest weighting configuration - * - * @hook ep_weighting_configuration_for_autosuggest - * @param {array} $config Configuration - * @return {array} New config - */ - $config = apply_filters( 'ep_weighting_configuration_for_autosuggest', $config ); - return $config; - } - - /** - * Store intercepted request value and return a fake successful request result - * - * @param array $response Response - * @param array $query ES Query - * @param array $args Request arguments - * @param int $failures Number of request failures - * @return array $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 ); - } - - /** - * Tell user whether requirements for feature are met or not. - * - * @return array $status Status array - * @since 2.4 - */ - 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 4.0.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 Instant Results 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; - } - - /** - * Do a non-blocking search query to force the autosuggest hash to update. - * - * This request has to happen in a public environment, so all code testing if `is_admin()` - * are properly executed. - * - * @param bool $blocking If the request should block the execution or not. - */ - public function epio_send_autosuggest_public_request( $blocking = false ) { - if ( ! Utils\is_epio() ) { - return; - } - - $url = add_query_arg( - array( - 's' => 'search test', - 'ep_epio_set_autosuggest' => 1, - 'ep_epio_nonce' => wp_create_nonce( 'ep-epio-set-autosuggest' ), - 'nocache' => time(), // Here just to avoid the request hitting a CDN. - ), - home_url( '/' ) - ); - - // Pass the same cookies, so the same authenticated user is used (and we can check the nonce). - $cookies = array(); - foreach ( $_COOKIE as $name => $value ) { - if ( ! is_string( $name ) || ! is_string( $value ) ) { - continue; - } - - $cookies[] = new \WP_Http_Cookie( - array( - 'name' => $name, - 'value' => $value, - ) - ); - } - - wp_remote_get( - $url, - array( - 'cookies' => $cookies, - 'blocking' => (bool) $blocking, - ) - ); - } - - /** - * Send the allowed parameters for autosuggest to ElasticPress.io. - */ - public function epio_send_autosuggest_allowed() { - if ( empty( $_REQUEST['ep_epio_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_REQUEST['ep_epio_nonce'] ), 'ep-epio-set-autosuggest' ) ) { - return; - } - - if ( empty( $_GET['ep_epio_set_autosuggest'] ) ) { - return; - } - - /** - * Fires before the request is sent to EP.io to set Autosuggest allowed values. - * - * @hook ep_epio_pre_send_autosuggest_allowed - * @since 3.5.x - */ - do_action( 'ep_epio_pre_send_autosuggest_allowed' ); - - /** - * The same ES query sent by autosuggest. - * - * Sometimes it'll be a string, sometimes it'll be already an array. - */ - $es_search_query = $this->generate_search_query()['body']; - $es_search_query = ( is_array( $es_search_query ) ) ? $es_search_query : json_decode( $es_search_query, true ); - - /** - * Filter autosuggest ES query - * - * @since 3.5.x - * @hook ep_epio_autosuggest_es_query - * @param {array} The ES Query. - */ - $es_search_query = apply_filters( 'ep_epio_autosuggest_es_query', $es_search_query ); - - /** - * Here is a chance to short-circuit the execution. Also, during the sync - * the query will be empty anyway. - */ - if ( empty( $es_search_query ) ) { - return; - } - - $index = Indexables::factory()->get( 'post' )->get_index_name(); - - add_filter( 'ep_format_request_headers', array( $this, 'add_ep_set_autosuggest_header' ) ); - - Elasticsearch::factory()->query( $index, 'post', $es_search_query, array() ); - - remove_filter( 'ep_format_request_headers', array( $this, 'add_ep_set_autosuggest_header' ) ); - - /** - * Fires after the request is sent to EP.io to set Autosuggest allowed values. - * - * @hook ep_epio_sent_autosuggest_allowed - * @since 3.5.x - */ - do_action( 'ep_epio_sent_autosuggest_allowed' ); - } - - /** - * Set a header so EP.io servers know this request contains the values - * that should be stored as allowed. - * - * @since 3.5.x - * @param array $headers The Request Headers. - * @return array - */ - public function add_ep_set_autosuggest_header( $headers ) { - $headers['EP-Set-Autosuggest'] = true; - return $headers; - } - - /** - * Retrieve the allowed parameters for autosuggest from ElasticPress.io. - * - * @return array - */ - public function epio_retrieve_autosuggest_allowed() { - $response = Elasticsearch::factory()->remote_request( - Indexables::factory()->get( 'post' )->get_index_name() . '/get-autosuggest-allowed' - ); - - $body = wp_remote_retrieve_body( $response, true ); - return json_decode( $body, true ); - } - - /** - * Output the current allowed parameters for autosuggest stored in ElasticPress.io. - */ - public function epio_allowed_parameters() { - global $wp_version; - - $allowed_params = $this->epio_autosuggest_set_and_get(); - if ( empty( $allowed_params ) ) { - return; - } - ?> -
    -
    -
    - tag (ElasticPress.io); 2. ; 3: tag (KB article); 4. ; 5: tag (Site Health Debug Section); 6. ; */ - esc_html__( 'You are directly connected to %1$sElasticPress.io%2$s, ensuring the most performant Autosuggest experience. %3$sLearn more about what this means%4$s or %5$sclick here for debug information%6$s.', 'elasticpress' ), - '', - '', - '', - '', - '', - '' - ); - ?> -
    -
    - epio_retrieve_autosuggest_allowed(); - - if ( is_wp_error( $allowed_params ) || ( isset( $allowed_params['status'] ) && 200 !== $allowed_params['status'] ) ) { - $allowed_params = array(); - break; - } - - // We have what we need, no need to retry. - if ( ! empty( $allowed_params ) ) { - break; - } - - // Send to EP.io what should be autosuggest's allowed values and try to get them again. - $this->epio_send_autosuggest_public_request( true ); - } - - return $allowed_params; - } - - /** - * Return true, so EP knows we want to intercept the remote request - * - * As we add and remove this function from `ep_intercept_remote_request`, - * using `__return_true` could remove a *real* `__return_true` added by someone else. - * - * @since 4.7.0 - * @see https://github.com/10up/ElasticPress/issues/2887 - * @return true - */ - public function intercept_remote_request() { - return true; - } - - /** - * Conditionally add EP.io information to the settings schema - * - * @since 5.0.0 - */ - protected function maybe_add_epio_settings_schema() { - if ( ! Utils\is_epio() ) { - return; - } - - $epio_link = 'https://elasticpress.io'; - $epio_autosuggest_kb_link = 'https://www.elasticpress.io/documentation/article/elasticpress-io-autosuggest/'; - $status_report_link = defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ? network_admin_url( 'admin.php?page=elasticpress-status-report' ) : admin_url( 'admin.php?page=elasticpress-status-report' ); - - $this->settings_schema[] = array( - 'key' => 'epio', - 'label' => sprintf( - /* translators: 1: tag (ElasticPress.io); 2. ; 3: tag (KB article); 4. ; 5: tag (Site Health Debug Section); 6. ; */ - __( 'You are directly connected to %1$sElasticPress.io%2$s, ensuring the most performant Autosuggest experience. %3$sLearn more about what this means%4$s or %5$sclick here for debug information%6$s.', 'elasticpress' ), - '', - '', - '', - '', - '', - '' - ), - 'type' => 'markup', - ); - } - /** * Set the `settings_schema` attribute * - * @since 5.0.0 + * @since 5.3.0 */ protected function set_settings_schema() { - $this->settings_schema = array( - array( - 'default' => '.ep-autosuggest', - 'help' => __( 'Input additional selectors where you would like to include autosuggest, separated by a comma. Example: .custom-selector, #custom-id, input[type="text"]', 'elasticpress' ), - 'key' => 'autosuggest_selector', - 'label' => __( 'Additional selectors', 'elasticpress' ), - 'type' => 'text', - ), - array( - 'default' => '0', - 'key' => 'trigger_ga_event', - 'help' => __( 'Enable to fire a gtag tracking event when an autosuggest result is clicked.', 'elasticpress' ), - 'label' => __( 'Trigger Google Analytics events', 'elasticpress' ), - 'type' => 'checkbox', - ), - ); - - $this->maybe_add_epio_settings_schema(); - - if ( ! Utils\is_epio() ) { - $set_in_wp_config = defined( 'EP_AUTOSUGGEST_ENDPOINT' ) && EP_AUTOSUGGEST_ENDPOINT; - - $this->settings_schema[] = array( - 'disabled' => $set_in_wp_config, - 'help' => ! $set_in_wp_config ? __( 'A valid URL starting with http:// or https://. This address will be exposed to the public.', 'elasticpress' ) : '', - 'key' => 'endpoint_url', - 'label' => __( 'Endpoint URL', 'elasticpress' ), - 'type' => 'url', - ); - } - } - - /** - * DEPRECATED. Delete the cached query for autosuggest. - * - * @since 3.5.5 - */ - public function delete_cached_query() { - _doing_it_wrong( - __METHOD__, - esc_html__( 'This method should not be called anymore, as autosuggest requests are not sent regularly anymore.' ), - 'ElasticPress 4.7.0' - ); - } - /** - * Get the contexts for autosuggest. - * - * @since 5.1.0 - * @return array - */ - protected function get_contexts(): array { - /** - * Filter contexts for autosuggest. - * - * @hook ep_autosuggest_contexts - * @since 5.1.0 - * @param {array} $contexts Contexts for autosuggest - * @return {array} New contexts - */ - return apply_filters( 'ep_autosuggest_contexts', array( 'public', 'ajax' ) ); + $this->settings_schema = [ + [ + 'default' => get_option( 'posts_per_page', 6 ), + 'key' => 'per_page', + 'type' => 'hidden', + ], + ]; } } From b00d06ff4b9ed1beab5c5c1a5f74285da1f15240 Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Thu, 29 May 2025 18:40:15 -0500 Subject: [PATCH 13/20] Add proxy and tests --- .wp-env.json | 6 +- .../integration/features/autosuggestv2.cy.js | 100 ++++ .../autosuggestv2-proxy-plugin.php | 72 +++ .../test-plugins/autosuggestv2-proxy.php | 477 ++++++++++++++++++ .../test-plugins/customize-autosuggest-v2.js | 202 ++++++++ .../test-plugins/customize-autosuggest-v2.php | 32 ++ 6 files changed, 888 insertions(+), 1 deletion(-) create mode 100644 tests/cypress/integration/features/autosuggestv2.cy.js create mode 100644 tests/cypress/wordpress-files/test-plugins/autosuggestv2-proxy-plugin.php create mode 100644 tests/cypress/wordpress-files/test-plugins/autosuggestv2-proxy.php create mode 100644 tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.js create mode 100644 tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.php diff --git a/.wp-env.json b/.wp-env.json index 65868db282..026ce52695 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -40,7 +40,11 @@ "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/customize-autosuggest-v2.php": "./tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.php", + "wp-content/plugins/customize-autosuggest-v2.js": "./tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.js", + "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/tests/cypress/integration/features/autosuggestv2.cy.js b/tests/cypress/integration/features/autosuggestv2.cy.js new file mode 100644 index 0000000000..26bd1f35be --- /dev/null +++ b/tests/cypress/integration/features/autosuggestv2.cy.js @@ -0,0 +1,100 @@ +/* global isEpIo */ + +// eslint-disable-next-line jest/valid-describe-callback +describe('Autosuggest V2 Feature', () => { + before(() => { + cy.deactivatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); + cy.maybeDisableFeature('autosuggest-v2'); + cy.maybeDisableFeature('autosuggest'); + cy.deactivatePlugin('customize-autosuggest-v2', 'wpCli'); + }); + + /** + * 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'); + }); + + /** + * Test that the feature works after being activated + */ + it('Displays autosuggestions after being enabled', () => { + cy.activatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); + + cy.reload(); + + cy.visitAdminPage('admin.php?page=elasticpress#/autosuggest-v2'); + + cy.intercept('/wp-json/elasticpress/v1/features*').as('apiRequest'); + + cy.get('.components-form-toggle__input').should('not.be.disabled'); + + const noticeShould = isEpIo ? 'not.contain.text' : 'contain.text'; + + cy.get('.components-notice').should(noticeShould, 'You are using a custom proxy.'); + + cy.contains('label', 'Enable').click(); + cy.contains('button', 'Save and sync now').click(); + + cy.wait('@apiRequest'); + + cy.on('window:confirm', () => true); + + cy.get('.ep-sync-progress strong', { + timeout: Cypress.config('elasticPressIndexTimeout'), + }).should('contain.text', 'Sync complete'); + + cy.timeout(2000); + + cy.visit('/'); + + cy.get('.wp-block-search').last().as('searchBlock'); + + cy.get('.wp-block-search__input').type('Markup: HTML Tags and Formatting'); + + cy.timeout(2000); + + 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'); + }); + }); + + /** + * Test that the feature can be modified via filters in a custom plugin + */ + it('Can be customized using filters', () => { + cy.activatePlugin('customize-autosuggest-v2', 'wpCli'); + cy.visit('/'); + cy.get('.wp-block-search').last().as('searchBlock'); + + cy.get('.wp-block-search__input').type('Markup: HTML Tags and Formatting'); + + cy.timeout(2000); + + cy.get('.ep-autosuggest').should(($autosuggestList) => { + // eslint-disable-next-line no-unused-expressions + expect($autosuggestList).to.be.visible; + expect($autosuggestList[0].innerText).to.contains('Custom Search Results'); + expect($autosuggestList[0].innerText).to.contains('Type:'); + expect($autosuggestList[0].innerText).to.contains('Markup: HTML Tags and Formatting'); + }); + }); + + after(() => { + cy.deactivatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); + cy.maybeDisableFeature('autosuggest-v2'); + cy.deactivatePlugin('customize-autosuggest-v2', 'wpCli'); + }); +}); 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..61526ebab0 --- /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 $string String to be sanitized + * @return string + */ + protected function sanitize_string( $string ) { + return htmlspecialchars( $string ); + } + + /** + * Utilitary function to sanitize numbers. + * + * @param string $string Number to be sanitized + * @return string + */ + protected function sanitize_number( $string ) { + return filter_var( $string, FILTER_SANITIZE_NUMBER_INT ); + } +} + +$ep_as_php_proxy = new EP_AS_PHP_Proxy(); +$ep_as_php_proxy->proxy(); diff --git a/tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.js b/tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.js new file mode 100644 index 0000000000..0cce4d591b --- /dev/null +++ b/tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.js @@ -0,0 +1,202 @@ +/** + * Child Theme js/index.js + */ + +const { createElement: e } = wp.element; + +function registerAutosuggestCustomizations() { + // 1. Hook: ep.Autosuggest.queryParams + wp.hooks.addFilter( + 'ep.Autosuggest.queryParams', + 'my-theme/filter-search-by-author', + function (params, searchTerm) { + const newParams = new URLSearchParams(params.toString()); + if (searchTerm.toLowerCase().includes('by:admin')) { + newParams.set('author_name', 'admin'); + } + newParams.set('custom_param', 'custom_value'); + return newParams; + }, + ); + + // 2. Hook: ep.Autosuggest.suggestions + wp.hooks.addFilter( + 'ep.Autosuggest.suggestions', + 'my-theme/prioritize-pages', + function (searchResults) { + if (!Array.isArray(searchResults)) { + return searchResults; + } + const pages = searchResults.filter( + (item) => item._source && item._source.post_type === 'page', + ); + const others = searchResults.filter( + (item) => !item._source || item._source.post_type !== 'page', + ); + const prioritizedResults = [...pages, ...others]; + return prioritizedResults.map((item) => ({ + ...item, + _source: { + ...item._source, + customFlag: + item._source.post_type === 'page' ? 'Priority Content' : 'Standard Content', + }, + })); + }, + ); + + // 3. Hook: ep.Autosuggest.suggestionItem + const MyCustomSuggestionItem = (props) => { + const { suggestion, isActive, onClick } = props; + const itemClasses = `my-custom-item ${isActive ? 'my-active-item' : ''}`; + + return e( + 'li', + { + className: itemClasses, + role: 'option', + 'aria-selected': isActive, + id: `custom-suggestion-${suggestion.id}`, + onMouseDown: onClick, + tabIndex: -1, + }, + e( + 'a', + { href: suggestion.url }, + // Thumbnail (conditional) + suggestion.thumbnail && + e('img', { + src: suggestion.thumbnail, + alt: '', + className: 'item-thumbnail', + }), + // Content div + e( + 'div', + { className: 'item-content' }, + e('strong', { className: 'item-title' }, suggestion.title), + suggestion.category && + e( + 'span', + { className: 'item-meta-category' }, + 'Category: ', + suggestion.category, + ), + e('span', { className: 'item-meta-type' }, 'Type: ', suggestion.type), + suggestion._source.customFlag && + e('p', { className: 'item-custom-flag' }, suggestion._source.customFlag), + ), + ), + ); + }; + + wp.hooks.addFilter( + 'ep.Autosuggest.suggestionItem', + 'my-theme/custom-suggestion-item-renderer', + function (props) { + return { + ...props, + renderSuggestion: () => e(MyCustomSuggestionItem, props), + }; + }, + ); + + // 4. Hook: ep.Autosuggest.suggestionList (Modified to use createElement) + const MyCustomSuggestionList = (props) => { + const { + suggestions, + activeIndex, + onItemClick, + SuggestionItemTemplate, + showViewAll, + onViewAll, + expanded, + } = props; + + if (!suggestions.length) { + return null; + } + + const groupedSuggestions = suggestions.reduce((acc, suggestion) => { + const type = suggestion.type || 'other'; + if (!acc[type]) { + acc[type] = []; + } + acc[type].push(suggestion); + return acc; + }, {}); + + return e( + 'div', + { className: 'my-custom-suggestion-list-wrapper' }, + e('h3', { className: 'list-main-title' }, 'Custom Search Results'), + // Map over grouped suggestions + ...Object.entries(groupedSuggestions).map(([type, items]) => + e( + 'div', + { key: type, className: 'suggestion-group' }, + e( + 'h4', + { className: 'suggestion-group-title' }, + `${type.charAt(0).toUpperCase() + type.slice(1)}s (${items.length})`, + ), + e( + 'ul', + { className: 'custom-group-items', role: 'listbox' }, + ...items.map((suggestion) => { + const originalIndex = suggestions.findIndex( + (s) => s.id === suggestion.id, + ); + return e(SuggestionItemTemplate, { + key: suggestion.id, + suggestion, + isActive: originalIndex === activeIndex, + onClick: () => onItemClick(originalIndex), + }); + }), + ), + ), + ), + // View All button (conditional) + showViewAll && + e( + 'button', + { + className: 'my-custom-view-all', + onClick: onViewAll, + type: 'button', + }, + expanded ? 'View Less Results' : 'View All Results', + ), + ); + }; + + wp.hooks.addFilter( + 'ep.Autosuggest.suggestionList', + 'my-theme/custom-suggestion-list-renderer', + function (listProps) { + return { + ...listProps, + renderSuggestionList: () => e(MyCustomSuggestionList, listProps), + }; + }, + ); + + // 5. Hook: ep.Autosuggest.onItemClick (Action Hook - No changes from previous example) + wp.hooks.addAction( + 'ep.Autosuggest.onItemClick', + 'my-theme/track-suggestion-clicks', + function (suggestion, index, inputValue) { + // eslint-disable-next-line no-console + console.log('Suggestion Clicked (Action Hook):', { + title: suggestion.title, + url: suggestion.url, + type: suggestion.type, + index, + searchTerm: inputValue, + }); + }, + ); +} + +document.addEventListener('ep_autosuggest_loaded', registerAutosuggestCustomizations); diff --git a/tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.php b/tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.php new file mode 100644 index 0000000000..23c1012c2f --- /dev/null +++ b/tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.php @@ -0,0 +1,32 @@ +registered['wp-hooks']->extra['data'] ) ) { + wp_add_inline_script( + 'wp-hooks', + 'window.wp = window.wp || {}; window.wp.hooks = window.wp.hooks || {};', + 'before' + ); + } +} +add_action( 'wp_enqueue_scripts', 'mytheme_enqueue_assets' ); From 728444846e260ef6486e5c33d4ea910702eff63e Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Thu, 29 May 2025 18:45:51 -0500 Subject: [PATCH 14/20] Small linting fix --- .../test-plugins/autosuggestv2-proxy.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/cypress/wordpress-files/test-plugins/autosuggestv2-proxy.php b/tests/cypress/wordpress-files/test-plugins/autosuggestv2-proxy.php index 61526ebab0..79879af197 100644 --- a/tests/cypress/wordpress-files/test-plugins/autosuggestv2-proxy.php +++ b/tests/cypress/wordpress-files/test-plugins/autosuggestv2-proxy.php @@ -455,21 +455,21 @@ protected function return_response() { /** * Utilitary function to sanitize string. * - * @param string $string String to be sanitized + * @param string $str String to be sanitized * @return string */ - protected function sanitize_string( $string ) { - return htmlspecialchars( $string ); + protected function sanitize_string( $str ) { + return htmlspecialchars( $str ); } /** * Utilitary function to sanitize numbers. * - * @param string $string Number to be sanitized + * @param string $str Number to be sanitized * @return string */ - protected function sanitize_number( $string ) { - return filter_var( $string, FILTER_SANITIZE_NUMBER_INT ); + protected function sanitize_number( $str ) { + return filter_var( $str, FILTER_SANITIZE_NUMBER_INT ); } } From 0eceb15a5a86ba0679c497d3f7a2d7caf7702159 Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Thu, 29 May 2025 20:03:21 -0500 Subject: [PATCH 15/20] Minor test changes --- .../integration/features/autosuggestv2.cy.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/cypress/integration/features/autosuggestv2.cy.js b/tests/cypress/integration/features/autosuggestv2.cy.js index 26bd1f35be..c316c24959 100644 --- a/tests/cypress/integration/features/autosuggestv2.cy.js +++ b/tests/cypress/integration/features/autosuggestv2.cy.js @@ -1,3 +1,4 @@ +/* eslint-disable cypress/no-unnecessary-waiting */ /* global isEpIo */ // eslint-disable-next-line jest/valid-describe-callback @@ -8,7 +9,6 @@ describe('Autosuggest V2 Feature', () => { cy.maybeDisableFeature('autosuggest'); cy.deactivatePlugin('customize-autosuggest-v2', 'wpCli'); }); - /** * Test that the feature cannot be activated when not in ElasticPress.io nor using a custom PHP proxy. */ @@ -29,12 +29,16 @@ describe('Autosuggest V2 Feature', () => { * Test that the feature works after being activated */ it('Displays autosuggestions after being enabled', () => { - cy.activatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); + if (!isEpIo) { + cy.activatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); + } cy.reload(); cy.visitAdminPage('admin.php?page=elasticpress#/autosuggest-v2'); + cy.reload(); + cy.intercept('/wp-json/elasticpress/v1/features*').as('apiRequest'); cy.get('.components-form-toggle__input').should('not.be.disabled'); @@ -44,6 +48,7 @@ describe('Autosuggest V2 Feature', () => { cy.get('.components-notice').should(noticeShould, 'You are using a custom proxy.'); cy.contains('label', 'Enable').click(); + cy.wait(1000); cy.contains('button', 'Save and sync now').click(); cy.wait('@apiRequest'); @@ -54,7 +59,7 @@ describe('Autosuggest V2 Feature', () => { timeout: Cypress.config('elasticPressIndexTimeout'), }).should('contain.text', 'Sync complete'); - cy.timeout(2000); + cy.wait(2000); cy.visit('/'); @@ -62,7 +67,7 @@ describe('Autosuggest V2 Feature', () => { cy.get('.wp-block-search__input').type('Markup: HTML Tags and Formatting'); - cy.timeout(2000); + cy.wait(2000); cy.get('.ep-autosuggest').should(($autosuggestList) => { // eslint-disable-next-line no-unused-expressions @@ -93,8 +98,8 @@ describe('Autosuggest V2 Feature', () => { }); after(() => { - cy.deactivatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); cy.maybeDisableFeature('autosuggest-v2'); + cy.deactivatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); cy.deactivatePlugin('customize-autosuggest-v2', 'wpCli'); }); }); From 9436a1ef97e00fbcb569451dff717dd87a1442bb Mon Sep 17 00:00:00 2001 From: Zach Date: Thu, 29 May 2025 22:29:49 -0500 Subject: [PATCH 16/20] Update test --- .../integration/features/autosuggestv2.cy.js | 35 +++++++------------ 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/tests/cypress/integration/features/autosuggestv2.cy.js b/tests/cypress/integration/features/autosuggestv2.cy.js index c316c24959..cabd2a0187 100644 --- a/tests/cypress/integration/features/autosuggestv2.cy.js +++ b/tests/cypress/integration/features/autosuggestv2.cy.js @@ -5,9 +5,9 @@ describe('Autosuggest V2 Feature', () => { before(() => { cy.deactivatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); + cy.deactivatePlugin('customize-autosuggest-v2', 'wpCli'); cy.maybeDisableFeature('autosuggest-v2'); cy.maybeDisableFeature('autosuggest'); - cy.deactivatePlugin('customize-autosuggest-v2', 'wpCli'); }); /** * Test that the feature cannot be activated when not in ElasticPress.io nor using a custom PHP proxy. @@ -33,25 +33,19 @@ describe('Autosuggest V2 Feature', () => { cy.activatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); } - cy.reload(); - - cy.visitAdminPage('admin.php?page=elasticpress#/autosuggest-v2'); - - cy.reload(); - - cy.intercept('/wp-json/elasticpress/v1/features*').as('apiRequest'); - - cy.get('.components-form-toggle__input').should('not.be.disabled'); + cy.visitAdminPage('admin.php?page=elasticpress'); - const noticeShould = isEpIo ? 'not.contain.text' : 'contain.text'; + cy.contains('button', 'Live Search').click(); + cy.contains('button', 'Autosuggest V2').click(); - cy.get('.components-notice').should(noticeShould, 'You are using a custom proxy.'); + if (!isEpIo) { + cy.get('.components-notice').should('contain.text', 'You are using a custom proxy.'); + } - cy.contains('label', 'Enable').click(); - cy.wait(1000); - cy.contains('button', 'Save and sync now').click(); + cy.maybeEnableFeature('autosuggest-v2'); - cy.wait('@apiRequest'); + cy.visitAdminPage('admin.php?page=elasticpress-sync'); + cy.contains('.components-button', 'Start sync').click(); cy.on('window:confirm', () => true); @@ -59,15 +53,11 @@ describe('Autosuggest V2 Feature', () => { timeout: Cypress.config('elasticPressIndexTimeout'), }).should('contain.text', 'Sync complete'); - cy.wait(2000); - cy.visit('/'); - cy.get('.wp-block-search').last().as('searchBlock'); - cy.get('.wp-block-search__input').type('Markup: HTML Tags and Formatting'); - cy.wait(2000); + cy.wait(500); cy.get('.ep-autosuggest').should(($autosuggestList) => { // eslint-disable-next-line no-unused-expressions @@ -82,11 +72,10 @@ describe('Autosuggest V2 Feature', () => { it('Can be customized using filters', () => { cy.activatePlugin('customize-autosuggest-v2', 'wpCli'); cy.visit('/'); - cy.get('.wp-block-search').last().as('searchBlock'); cy.get('.wp-block-search__input').type('Markup: HTML Tags and Formatting'); - cy.timeout(2000); + cy.wait(500); cy.get('.ep-autosuggest').should(($autosuggestList) => { // eslint-disable-next-line no-unused-expressions From 9c89d561d468d37e2630cad3a10f122077907c25 Mon Sep 17 00:00:00 2001 From: Zach Date: Thu, 29 May 2025 23:03:09 -0500 Subject: [PATCH 17/20] Add disable step to test --- .../integration/features/autosuggestv2.cy.js | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/cypress/integration/features/autosuggestv2.cy.js b/tests/cypress/integration/features/autosuggestv2.cy.js index cabd2a0187..2e31725c08 100644 --- a/tests/cypress/integration/features/autosuggestv2.cy.js +++ b/tests/cypress/integration/features/autosuggestv2.cy.js @@ -86,9 +86,28 @@ describe('Autosuggest V2 Feature', () => { }); }); - after(() => { + it('Can be disabled', () => { cy.maybeDisableFeature('autosuggest-v2'); cy.deactivatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); cy.deactivatePlugin('customize-autosuggest-v2', 'wpCli'); + + cy.wait(2000); + + 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.wait(2000); + + cy.contains('button', 'Save changes').click(); + + cy.wait(2000); + + cy.reload(); + + cy.wait(2000); }); }); From 7518b66c59162b41ba1af2a03c02969e3ee1fc19 Mon Sep 17 00:00:00 2001 From: Zach Date: Fri, 30 May 2025 00:59:47 -0500 Subject: [PATCH 18/20] Adjust tests --- .wp-env.json | 6 +- .../integration/features/autosuggestv2.cy.js | 124 +++++------ .../test-plugins/customize-autosuggest-v2.js | 202 ------------------ .../test-plugins/customize-autosuggest-v2.php | 32 --- 4 files changed, 54 insertions(+), 310 deletions(-) delete mode 100644 tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.js delete mode 100644 tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.php diff --git a/.wp-env.json b/.wp-env.json index 026ce52695..a11eaa857b 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -41,10 +41,8 @@ "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/plugins/customize-autosuggest-v2.php": "./tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.php", - "wp-content/plugins/customize-autosuggest-v2.js": "./tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.js", - "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" + "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/tests/cypress/integration/features/autosuggestv2.cy.js b/tests/cypress/integration/features/autosuggestv2.cy.js index 2e31725c08..dee8f03946 100644 --- a/tests/cypress/integration/features/autosuggestv2.cy.js +++ b/tests/cypress/integration/features/autosuggestv2.cy.js @@ -25,89 +25,69 @@ describe('Autosuggest V2 Feature', () => { cy.get('.components-form-toggle__input').should('be.disabled'); }); - /** - * Test that the feature works after being activated - */ - it('Displays autosuggestions after being enabled', () => { - if (!isEpIo) { - cy.activatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); - } - - 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.maybeEnableFeature('autosuggest-v2'); - - cy.visitAdminPage('admin.php?page=elasticpress-sync'); - cy.contains('.components-button', 'Start sync').click(); + describe('Autosuggest V2 enabled', () => { + before(() => { + if (!isEpIo) { + cy.activatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); + } + cy.maybeEnableFeature('autosuggest-v2'); + cy.wpCli('wp elasticpress sync'); + }); - cy.on('window:confirm', () => true); + 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'); + }); - cy.get('.ep-sync-progress strong', { - timeout: Cypress.config('elasticPressIndexTimeout'), - }).should('contain.text', 'Sync complete'); + /** + * 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.visit('/'); - cy.get('.wp-block-search__input').type('Markup: HTML Tags and Formatting'); + cy.get('.wp-block-search__input').type('Markup: HTML Tags and Formatting'); - cy.wait(500); + 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'); + 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', + ); + }); }); }); - /** - * Test that the feature can be modified via filters in a custom plugin - */ - it('Can be customized using filters', () => { - cy.activatePlugin('customize-autosuggest-v2', 'wpCli'); - cy.visit('/'); - - cy.get('.wp-block-search__input').type('Markup: HTML Tags and Formatting'); - - cy.wait(500); - - cy.get('.ep-autosuggest').should(($autosuggestList) => { - // eslint-disable-next-line no-unused-expressions - expect($autosuggestList).to.be.visible; - expect($autosuggestList[0].innerText).to.contains('Custom Search Results'); - expect($autosuggestList[0].innerText).to.contains('Type:'); - expect($autosuggestList[0].innerText).to.contains('Markup: HTML Tags and Formatting'); + describe('Autosuggest V2 Disabled', () => { + before(() => { + cy.maybeDisableFeature('autosuggest-v2'); + cy.deactivatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); + cy.deactivatePlugin('customize-autosuggest-v2', 'wpCli'); }); - }); + it('Can be disabled', () => { + cy.visitAdminPage('admin.php?page=elasticpress'); + cy.contains('button', 'Live Search').click(); + cy.contains('button', 'Autosuggest V2').click(); - it('Can be disabled', () => { - cy.maybeDisableFeature('autosuggest-v2'); - cy.deactivatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); - cy.deactivatePlugin('customize-autosuggest-v2', 'wpCli'); - - cy.wait(2000); + cy.get('.components-toggle-control input:checked').should('not.exist'); + cy.get('.components-toggle-control input:not(:checked)').should('exist'); - 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.wait(2000); - - cy.contains('button', 'Save changes').click(); - - cy.wait(2000); - - cy.reload(); - - cy.wait(2000); + cy.contains('button', 'Save changes').click(); + }); }); }); diff --git a/tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.js b/tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.js deleted file mode 100644 index 0cce4d591b..0000000000 --- a/tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.js +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Child Theme js/index.js - */ - -const { createElement: e } = wp.element; - -function registerAutosuggestCustomizations() { - // 1. Hook: ep.Autosuggest.queryParams - wp.hooks.addFilter( - 'ep.Autosuggest.queryParams', - 'my-theme/filter-search-by-author', - function (params, searchTerm) { - const newParams = new URLSearchParams(params.toString()); - if (searchTerm.toLowerCase().includes('by:admin')) { - newParams.set('author_name', 'admin'); - } - newParams.set('custom_param', 'custom_value'); - return newParams; - }, - ); - - // 2. Hook: ep.Autosuggest.suggestions - wp.hooks.addFilter( - 'ep.Autosuggest.suggestions', - 'my-theme/prioritize-pages', - function (searchResults) { - if (!Array.isArray(searchResults)) { - return searchResults; - } - const pages = searchResults.filter( - (item) => item._source && item._source.post_type === 'page', - ); - const others = searchResults.filter( - (item) => !item._source || item._source.post_type !== 'page', - ); - const prioritizedResults = [...pages, ...others]; - return prioritizedResults.map((item) => ({ - ...item, - _source: { - ...item._source, - customFlag: - item._source.post_type === 'page' ? 'Priority Content' : 'Standard Content', - }, - })); - }, - ); - - // 3. Hook: ep.Autosuggest.suggestionItem - const MyCustomSuggestionItem = (props) => { - const { suggestion, isActive, onClick } = props; - const itemClasses = `my-custom-item ${isActive ? 'my-active-item' : ''}`; - - return e( - 'li', - { - className: itemClasses, - role: 'option', - 'aria-selected': isActive, - id: `custom-suggestion-${suggestion.id}`, - onMouseDown: onClick, - tabIndex: -1, - }, - e( - 'a', - { href: suggestion.url }, - // Thumbnail (conditional) - suggestion.thumbnail && - e('img', { - src: suggestion.thumbnail, - alt: '', - className: 'item-thumbnail', - }), - // Content div - e( - 'div', - { className: 'item-content' }, - e('strong', { className: 'item-title' }, suggestion.title), - suggestion.category && - e( - 'span', - { className: 'item-meta-category' }, - 'Category: ', - suggestion.category, - ), - e('span', { className: 'item-meta-type' }, 'Type: ', suggestion.type), - suggestion._source.customFlag && - e('p', { className: 'item-custom-flag' }, suggestion._source.customFlag), - ), - ), - ); - }; - - wp.hooks.addFilter( - 'ep.Autosuggest.suggestionItem', - 'my-theme/custom-suggestion-item-renderer', - function (props) { - return { - ...props, - renderSuggestion: () => e(MyCustomSuggestionItem, props), - }; - }, - ); - - // 4. Hook: ep.Autosuggest.suggestionList (Modified to use createElement) - const MyCustomSuggestionList = (props) => { - const { - suggestions, - activeIndex, - onItemClick, - SuggestionItemTemplate, - showViewAll, - onViewAll, - expanded, - } = props; - - if (!suggestions.length) { - return null; - } - - const groupedSuggestions = suggestions.reduce((acc, suggestion) => { - const type = suggestion.type || 'other'; - if (!acc[type]) { - acc[type] = []; - } - acc[type].push(suggestion); - return acc; - }, {}); - - return e( - 'div', - { className: 'my-custom-suggestion-list-wrapper' }, - e('h3', { className: 'list-main-title' }, 'Custom Search Results'), - // Map over grouped suggestions - ...Object.entries(groupedSuggestions).map(([type, items]) => - e( - 'div', - { key: type, className: 'suggestion-group' }, - e( - 'h4', - { className: 'suggestion-group-title' }, - `${type.charAt(0).toUpperCase() + type.slice(1)}s (${items.length})`, - ), - e( - 'ul', - { className: 'custom-group-items', role: 'listbox' }, - ...items.map((suggestion) => { - const originalIndex = suggestions.findIndex( - (s) => s.id === suggestion.id, - ); - return e(SuggestionItemTemplate, { - key: suggestion.id, - suggestion, - isActive: originalIndex === activeIndex, - onClick: () => onItemClick(originalIndex), - }); - }), - ), - ), - ), - // View All button (conditional) - showViewAll && - e( - 'button', - { - className: 'my-custom-view-all', - onClick: onViewAll, - type: 'button', - }, - expanded ? 'View Less Results' : 'View All Results', - ), - ); - }; - - wp.hooks.addFilter( - 'ep.Autosuggest.suggestionList', - 'my-theme/custom-suggestion-list-renderer', - function (listProps) { - return { - ...listProps, - renderSuggestionList: () => e(MyCustomSuggestionList, listProps), - }; - }, - ); - - // 5. Hook: ep.Autosuggest.onItemClick (Action Hook - No changes from previous example) - wp.hooks.addAction( - 'ep.Autosuggest.onItemClick', - 'my-theme/track-suggestion-clicks', - function (suggestion, index, inputValue) { - // eslint-disable-next-line no-console - console.log('Suggestion Clicked (Action Hook):', { - title: suggestion.title, - url: suggestion.url, - type: suggestion.type, - index, - searchTerm: inputValue, - }); - }, - ); -} - -document.addEventListener('ep_autosuggest_loaded', registerAutosuggestCustomizations); diff --git a/tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.php b/tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.php deleted file mode 100644 index 23c1012c2f..0000000000 --- a/tests/cypress/wordpress-files/test-plugins/customize-autosuggest-v2.php +++ /dev/null @@ -1,32 +0,0 @@ -registered['wp-hooks']->extra['data'] ) ) { - wp_add_inline_script( - 'wp-hooks', - 'window.wp = window.wp || {}; window.wp.hooks = window.wp.hooks || {};', - 'before' - ); - } -} -add_action( 'wp_enqueue_scripts', 'mytheme_enqueue_assets' ); From c273f023e04e0d9ba84991bb17ed09fafea155bd Mon Sep 17 00:00:00 2001 From: Zach Date: Fri, 30 May 2025 01:19:46 -0500 Subject: [PATCH 19/20] Remove reference --- tests/cypress/integration/features/autosuggestv2.cy.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/cypress/integration/features/autosuggestv2.cy.js b/tests/cypress/integration/features/autosuggestv2.cy.js index dee8f03946..8f11223421 100644 --- a/tests/cypress/integration/features/autosuggestv2.cy.js +++ b/tests/cypress/integration/features/autosuggestv2.cy.js @@ -5,7 +5,6 @@ describe('Autosuggest V2 Feature', () => { before(() => { cy.deactivatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); - cy.deactivatePlugin('customize-autosuggest-v2', 'wpCli'); cy.maybeDisableFeature('autosuggest-v2'); cy.maybeDisableFeature('autosuggest'); }); @@ -77,7 +76,6 @@ describe('Autosuggest V2 Feature', () => { before(() => { cy.maybeDisableFeature('autosuggest-v2'); cy.deactivatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); - cy.deactivatePlugin('customize-autosuggest-v2', 'wpCli'); }); it('Can be disabled', () => { cy.visitAdminPage('admin.php?page=elasticpress'); From 963404b95874f9abcf0129ddebfda720f40cccc3 Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Fri, 30 May 2025 01:45:01 -0500 Subject: [PATCH 20/20] Test adjustment --- .../cypress/integration/features/autosuggestv2.cy.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/cypress/integration/features/autosuggestv2.cy.js b/tests/cypress/integration/features/autosuggestv2.cy.js index 8f11223421..83b073b1f3 100644 --- a/tests/cypress/integration/features/autosuggestv2.cy.js +++ b/tests/cypress/integration/features/autosuggestv2.cy.js @@ -2,12 +2,13 @@ /* global isEpIo */ // eslint-disable-next-line jest/valid-describe-callback -describe('Autosuggest V2 Feature', () => { +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. */ @@ -33,6 +34,13 @@ describe('Autosuggest V2 Feature', () => { 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'); @@ -74,6 +82,7 @@ describe('Autosuggest V2 Feature', () => { describe('Autosuggest V2 Disabled', () => { before(() => { + // This block already ensures its desired state cy.maybeDisableFeature('autosuggest-v2'); cy.deactivatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); });