diff --git a/.wp-env.json b/.wp-env.json index 65868db282..a11eaa857b 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -40,7 +40,9 @@ "wp-content/plugins/sync-error.php": "./tests/cypress/wordpress-files/test-plugins/sync-error.php", "wp-content/plugins/unsupported-server-software.php": "./tests/cypress/wordpress-files/test-plugins/unsupported-server-software.php", "wp-content/plugins/unsupported-elasticsearch-version.php": "./tests/cypress/wordpress-files/test-plugins/unsupported-elasticsearch-version.php", - "wp-content/uploads/content-example.xml": "./tests/cypress/wordpress-files/test-docs/content-example.xml" + "wp-content/uploads/content-example.xml": "./tests/cypress/wordpress-files/test-docs/content-example.xml", + "wp-content/plugins/autosuggestv2-proxy.php": "./tests/cypress/wordpress-files/test-plugins/autosuggestv2-proxy.php", + "wp-content/plugins/autosuggestv2-proxy-plugin.php": "./tests/cypress/wordpress-files/test-plugins/autosuggestv2-proxy-plugin.php" } } } diff --git a/assets/css/autosuggest-v2.css b/assets/css/autosuggest-v2.css new file mode 100644 index 0000000000..52243769f8 --- /dev/null +++ b/assets/css/autosuggest-v2.css @@ -0,0 +1,49 @@ +@import "./global/colors.css"; + +.ep-autosuggest-wrapper { + position: relative; + + & .ep-autosuggest { + background: var(--ep-c-white); + border: 1px solid var(--ep-c-white-gray); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + width: 100%; + z-index: 200; + + & ul { + list-style: none; + margin: 0 !important; + + & li { + font-family: sans-serif; + + & a { + color: var(--ep-c-black); + cursor: pointer; + display: block; + font-size: 14px; + font-weight: 700; + padding: 2px 10px; + text-decoration: none; + transition: background-color 0.15s, color 0.15s; + + &:hover, + &:active { + background-color: var(--ep-c-medium-white); + text-decoration: none; + } + } + } + } + } + + & .selected { + background-color: var(--ep-c-medium-white); + text-decoration: none; + } +} + +.ep-autosuggest-dropdown-container { + position: absolute; + width: 100%; +} diff --git a/assets/js/api-search/index.js b/assets/js/api-search/index.js index b112178b01..d3b8039d04 100644 --- a/assets/js/api-search/index.js +++ b/assets/js/api-search/index.js @@ -25,6 +25,8 @@ import { getUrlWithParams, } from './src/utilities'; +import { applyResultsFilter } from '../autosuggest-v2/hooks'; + /** * Instant Results context. */ @@ -42,6 +44,7 @@ const Context = createContext(); * @param {WPElement} props.children Component children. * @param {string} props.paramPrefix Prefix used to set and parse URL parameters. * @param {Function} props.onAuthError Function to run when request authentication fails. + * @param {boolean} props.useUrlParams Whether to use URL parameters for state (default true). * @returns {WPElement} Component. */ export const ApiSearchProvider = ({ @@ -53,17 +56,18 @@ export const ApiSearchProvider = ({ children, paramPrefix, onAuthError, + useUrlParams = true, }) => { /** * Any default args from the URL. */ const defaultArgsFromUrl = useMemo(() => { - if (!paramPrefix) { + if (!paramPrefix || !useUrlParams) { return {}; } return getArgsFromUrlParams(argsSchema, paramPrefix); - }, [argsSchema, paramPrefix]); + }, [argsSchema, paramPrefix, useUrlParams]); /** * All default args including defaults from the schema. @@ -81,8 +85,8 @@ export const ApiSearchProvider = ({ * Whether the provider is "on" by default. */ const defaultIsOn = useMemo(() => { - return Object.keys(defaultArgsFromUrl).length > 0; - }, [defaultArgsFromUrl]); + return useUrlParams ? Object.keys(defaultArgsFromUrl).length > 0 : false; + }, [defaultArgsFromUrl, useUrlParams]); /** * Set up fetch method. @@ -222,7 +226,7 @@ export const ApiSearchProvider = ({ * @returns {void} */ const pushState = useCallback(() => { - if (typeof paramPrefix === 'undefined') { + if (typeof paramPrefix === 'undefined' || !useUrlParams) { return; } @@ -243,7 +247,7 @@ export const ApiSearchProvider = ({ } else { window.history.replaceState(state, document.title, window.location.href); } - }, [argsSchema, paramPrefix]); + }, [argsSchema, paramPrefix, useUrlParams]); /** * Handle popstate event. @@ -252,7 +256,7 @@ export const ApiSearchProvider = ({ */ const onPopState = useCallback( (event) => { - if (typeof paramPrefix === 'undefined') { + if (typeof paramPrefix === 'undefined' || !useUrlParams) { return; } @@ -262,7 +266,7 @@ export const ApiSearchProvider = ({ popState(event.state); } }, - [paramPrefix], + [paramPrefix, useUrlParams], ); /** @@ -271,12 +275,14 @@ export const ApiSearchProvider = ({ * @returns {Function} A cleanup function. */ const handleInit = useCallback(() => { - window.addEventListener('popstate', onPopState); - - return () => { - window.removeEventListener('popstate', onPopState); - }; - }, [onPopState]); + if (useUrlParams) { + window.addEventListener('popstate', onPopState); + return () => { + window.removeEventListener('popstate', onPopState); + }; + } + return () => {}; + }, [onPopState, useUrlParams]); /** * Handle a change to search args. @@ -285,9 +291,9 @@ export const ApiSearchProvider = ({ */ const handleSearch = useCallback(() => { const handle = async () => { - const { args, isOn, isPoppingState } = stateRef.current; + const { args, isOn, isPoppingState, searchTerm } = stateRef.current; - if (!isPoppingState) { + if (!isPoppingState && useUrlParams) { pushState(); } @@ -306,6 +312,11 @@ export const ApiSearchProvider = ({ return; } + // Apply filters to search results if hooks are available + if (response.hits && response.hits.hits && !useUrlParams) { + response.hits.hits = applyResultsFilter(response.hits.hits, searchTerm); + } + setResults(response); } catch (e) { const errorMessage = sprintf( @@ -321,7 +332,7 @@ export const ApiSearchProvider = ({ }; handle(); - }, [argsSchema, fetchResults, pushState]); + }, [argsSchema, fetchResults, pushState, useUrlParams]); /** * Effects. @@ -372,6 +383,7 @@ export const ApiSearchProvider = ({ turnOff, suggestedTerms, isFirstSearch, + useUrlParams, }; return {children}; diff --git a/assets/js/autosuggest-v2/components/AutosuggestUI.js b/assets/js/autosuggest-v2/components/AutosuggestUI.js new file mode 100644 index 0000000000..37aa338c7e --- /dev/null +++ b/assets/js/autosuggest-v2/components/AutosuggestUI.js @@ -0,0 +1,59 @@ +import { useState } from 'react'; +import { useApiSearch } from '../../api-search'; +import SuggestionItem from './SuggestionItem'; // Original SuggestionItem component +import SuggestionList from './SuggestionList'; +import { useKeyboardInput } from '../hooks'; + +const AutosuggestUI = ({ inputEl, minLength = 2 }) => { + const { searchResults, searchFor } = useApiSearch(); + + const [inputValue, setInputValue] = useState(''); + const [activeIndex, setActiveIndex] = useState(-1); + const [show, setShow] = useState(false); + + 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, + setShow, + inputValue, + setInputValue, + suggestions, + activeIndex, + setActiveIndex, + searchFor, + }); + + const handleItemClick = (idx) => { + if (typeof wp !== 'undefined' && wp.hooks) { + wp.hooks.doAction('ep.Autosuggest.onItemClick', suggestions[idx], idx, inputValue); + } + window.location.href = suggestions[idx].url; + setShow(false); + }; + + const suggestionListProps = { + suggestions, + activeIndex, + onItemClick: handleItemClick, + SuggestionItemTemplate: SuggestionItem, + }; + + return ( +
+ {show && suggestions.length > 0 && } +
+ ); +}; + +export default AutosuggestUI; diff --git a/assets/js/autosuggest-v2/components/SuggestionItem.js b/assets/js/autosuggest-v2/components/SuggestionItem.js new file mode 100644 index 0000000000..087e45e20d --- /dev/null +++ b/assets/js/autosuggest-v2/components/SuggestionItem.js @@ -0,0 +1,38 @@ +import { applySuggestionItemFilter } from '../hooks'; + +const SuggestionItem = ({ suggestion, isActive, onClick }) => { + const filteredProps = applySuggestionItemFilter(suggestion, isActive, onClick); + + if (filteredProps.renderSuggestion) { + return filteredProps.renderSuggestion(); + } + + 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 new file mode 100644 index 0000000000..db02d3b6cd --- /dev/null +++ b/assets/js/autosuggest-v2/components/SuggestionList.js @@ -0,0 +1,30 @@ +import { applySuggestionListFilter } from '../hooks'; + +const SuggestionList = (props) => { + const filteredProps = applySuggestionListFilter(props); + if (filteredProps.renderSuggestionList) { + return filteredProps.renderSuggestionList(); + } + + const { suggestions, activeIndex, onItemClick, SuggestionItemTemplate } = filteredProps; + + return ( +
    +
    + {window.epasI18n?.searchIn && {window.epasI18n?.searchIn}} +
    + +
    + ); +}; + +export default SuggestionList; diff --git a/assets/js/autosuggest-v2/config.js b/assets/js/autosuggest-v2/config.js new file mode 100644 index 0000000000..5c55d77bf2 --- /dev/null +++ b/assets/js/autosuggest-v2/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/hooks.js b/assets/js/autosuggest-v2/hooks.js new file mode 100644 index 0000000000..6aa98f0ec0 --- /dev/null +++ b/assets/js/autosuggest-v2/hooks.js @@ -0,0 +1,166 @@ +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. + * + * @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} + * + */ +export const useKeyboardInput = ({ + inputEl, + minLength, + show, + setShow, + inputValue, + setInputValue, + suggestions, + activeIndex, + setActiveIndex, + searchFor, +}) => { + useEffect(() => { + if (!inputEl) { + 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]); +}; diff --git a/assets/js/autosuggest-v2/index.js b/assets/js/autosuggest-v2/index.js new file mode 100644 index 0000000000..f882c93982 --- /dev/null +++ b/assets/js/autosuggest-v2/index.js @@ -0,0 +1,113 @@ +import { createRoot } from '@wordpress/element'; +import { ApiSearchProvider } from '../api-search'; +import { apiEndpoint, apiHost, argsSchema, paramPrefix, requestIdBase } from './config'; +import AutosuggestUI from './components/AutosuggestUI'; + +/** + * 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'; + + // Create a wrapper for the input and the dropdown + const wrapper = document.createElement('div'); + wrapper.className = 'ep-autosuggest-wrapper'; + 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); + + // Render the AutosuggestUI component + const root = createRoot(dropdownContainer); + root.render( + + + , + ); +} + +/** + * 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, + }; + + const finalApiConfig = { ...defaultConfig, ...apiConfigOverrides }; + + const autosuggestSelector = 'input[type="search"], .ep-autosuggest, .search-field'; + + // 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); + } + }); + } + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + return observer; +} + +// --- Main Execution --- +if (typeof window !== 'undefined') { + // 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(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')); +} + +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..c4b978d00f --- /dev/null +++ b/assets/js/autosuggest-v2/readme.md @@ -0,0 +1,307 @@ +# ElasticPress Autosuggest V2 - Hooks Documentation + +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; + } +); + +``` + +### `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' + ); + 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' : ''}`; + + 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: () => , + }; + } +); + +``` + +### `ep.Autosuggest.suggestionList` + +**Type**: Filter +**Description**: Customizes the entire suggestion list container and layout. + +**Parameters**: + +- `listProps` (object): Contains suggestions array, activeIndex, click handlers, and other list properties + +**Returns**: Modified listProps object with custom renderSuggestionList function + +**Example**: + +```javascript +const MyCustomSuggestionList = (props) => { + 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 ( +
    +

    Custom Search Results

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

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

    +
      + {items.map((suggestion) => { + const originalIndex = suggestions.findIndex(s => s.id === suggestion.id); + return ( + onItemClick(originalIndex)} + /> + ); + })} +
    +
    + ))} + {showViewAll && ( + + )} +
    + ); +}; + +wp.hooks.addFilter( + 'ep.Autosuggest.suggestionList', + 'my-theme/custom-suggestion-list-renderer', + function (listProps) { + return { + ...listProps, + renderSuggestionList: () => , + }; + } +); + +``` + +### `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. 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..38c2079219 --- /dev/null +++ b/includes/classes/Feature/AutosuggestV2/AutosuggestV2.php @@ -0,0 +1,655 @@ +slug = 'autosuggest-v2'; + + $this->group = 'live-search'; + + $this->host = trailingslashit( Utils\get_host() ); + + $this->index = Indexables::factory()->get( 'post' )->get_index_name(); + + $this->is_woocommerce = function_exists( 'WC' ); + + $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(); + + parent::__construct(); + } + + /** + * Sets i18n strings. + * + * @return void + * @since 5.3.0 + */ + public function set_i18n_strings(): void { + $this->title = esc_html__( 'Autosuggest V2', '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' ) . '

    ' . + '

    ' . __( 'Requires an ElasticPress.io plan or a custom proxy to function.', 'elasticpress' ) . '

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