diff --git a/assets/css/woocommerce/admin/components/listbox.css b/assets/css/woocommerce/admin/components/listbox.css new file mode 100644 index 0000000000..1c20d644fa --- /dev/null +++ b/assets/css/woocommerce/admin/components/listbox.css @@ -0,0 +1,38 @@ +:root { + --ep-listbox-background: #fff; + --ep-listbox-background-selected: #f5f5f5; + --ep-listbox-border: #8c8f94; +} + +.ep-listbox { + background-color: var(--ep-listbox-background); + border: 1px solid var(--ep-listbox-border); + border-radius: 4px; + box-shadow: 0 2px 6px rgb(0 0 0 / 5%); + box-sizing: border-box; + display: none; + margin: 4px 0 0; + overflow: hidden; + padding: 2px; + position: absolute; + top: 100%; + width: 100%; + z-index: 1; + + &[aria-hidden="false"] { + display: block; + } +} + +.ep-listbox__option { + border: var(--wp-admin-border-width-focus) solid transparent; + border-radius: 4px; + margin: 0; + padding: 8px; + + &[aria-selected="true"] { + background-color: var(--ep-listbox-background-selected); + border-color: var(--wp-admin-theme-color); + cursor: pointer; + } +} diff --git a/assets/css/woocommerce/admin/components/shop-order.css b/assets/css/woocommerce/admin/components/shop-order.css new file mode 100644 index 0000000000..b6ebd5a5c2 --- /dev/null +++ b/assets/css/woocommerce/admin/components/shop-order.css @@ -0,0 +1,32 @@ +.ep-shop-order { + + & p { + line-height: 1.2; + margin: 0 0 0.25rem; + } + + & strong { + display: block; + + @nest [aria-selected="true"] & { + color: var(--wp-admin-theme-color); + } + } + + & footer { + align-items: center; + display: flex; + justify-content: space-between; + } + + & time { + line-height: 1; + } + + & .order-status { + font-size: 0.85em; + justify-content: center; + margin: 0; + min-width: 40%; + } +} diff --git a/assets/css/woocommerce/admin/orders.css b/assets/css/woocommerce/admin/orders.css new file mode 100644 index 0000000000..e8fc871fd0 --- /dev/null +++ b/assets/css/woocommerce/admin/orders.css @@ -0,0 +1,6 @@ +@import "components/listbox.css"; +@import "components/shop-order.css"; + +#posts-filter .search-box { + position: relative; +} diff --git a/assets/js/api-search/index.js b/assets/js/api-search/index.js index de5a6c7c82..ee23cd8bb0 100644 --- a/assets/js/api-search/index.js +++ b/assets/js/api-search/index.js @@ -36,11 +36,19 @@ const Context = createContext(); * @param {string} props.apiEndpoint API endpoint. * @param {string} props.apiHost API Host. * @param {object} props.argsSchema Schema describing supported args. + * @param {string} props.authorization Authorization header. * @param {WPElement} props.children Component children. * @param {string} props.paramPrefix Prefix used to set and parse URL parameters. * @returns {WPElement} Component. */ -export const ApiSearchProvider = ({ apiEndpoint, apiHost, argsSchema, children, paramPrefix }) => { +export const ApiSearchProvider = ({ + apiEndpoint, + apiHost, + authorization, + argsSchema, + children, + paramPrefix, +}) => { /** * Any default args from the URL. */ @@ -74,7 +82,7 @@ export const ApiSearchProvider = ({ apiEndpoint, apiHost, argsSchema, children, /** * Set up fetch method. */ - const fetchResults = useFetchResults(apiHost, apiEndpoint); + const fetchResults = useFetchResults(apiHost, apiEndpoint, authorization); /** * Set up the reducer. @@ -109,6 +117,15 @@ export const ApiSearchProvider = ({ apiEndpoint, apiHost, argsSchema, children, dispatch({ type: 'CLEAR_CONSTRAINTS' }); }, []); + /** + * Clear search resu;ts. + * + * @returns {void} + */ + const clearResults = useCallback(() => { + dispatch({ type: 'CLEAR_RESULTS' }); + }, []); + /** * Update the search query args, triggering a search. * @@ -253,7 +270,7 @@ export const ApiSearchProvider = ({ apiEndpoint, apiHost, argsSchema, children, * * @returns {void} */ - const handleSearch = useCallback(() => { + const handleSearch = useCallback(async () => { const { args, isOn, isPoppingState } = stateRef.current; if (!isPoppingState) { @@ -268,10 +285,14 @@ export const ApiSearchProvider = ({ apiEndpoint, apiHost, argsSchema, children, setIsLoading(true); - fetchResults(urlParams).then((response) => { - setResults(response); - setIsLoading(false); - }); + const response = await fetchResults(urlParams); + + if (!response) { + return; + } + + setResults(response); + setIsLoading(false); }, [argsSchema, fetchResults, pushState]); /** @@ -298,6 +319,7 @@ export const ApiSearchProvider = ({ apiEndpoint, apiHost, argsSchema, children, aggregations, args, clearConstraints, + clearResults, getUrlParamsFromArgs, getUrlWithParams, isLoading, diff --git a/assets/js/api-search/src/hooks.js b/assets/js/api-search/src/hooks.js index bd7eb62df0..fde6858d84 100644 --- a/assets/js/api-search/src/hooks.js +++ b/assets/js/api-search/src/hooks.js @@ -8,9 +8,10 @@ import { useCallback, useRef } from '@wordpress/element'; * * @param {string} apiHost API host. * @param {string} apiEndpoint API endpoint. + * @param {string} Authorization Authorization header. * @returns {Function} Function for retrieving search results. */ -export const useFetchResults = (apiHost, apiEndpoint) => { +export const useFetchResults = (apiHost, apiEndpoint, Authorization) => { const abort = useRef(new AbortController()); const request = useRef(null); @@ -30,6 +31,7 @@ export const useFetchResults = (apiHost, apiEndpoint) => { signal: abort.current.signal, headers: { Accept: 'application/json', + Authorization, }, }) .then((response) => { @@ -47,5 +49,5 @@ export const useFetchResults = (apiHost, apiEndpoint) => { return request.current; }; - return useCallback(fetchResults, [apiHost, apiEndpoint]); + return useCallback(fetchResults, [apiHost, apiEndpoint, Authorization]); }; diff --git a/assets/js/api-search/src/reducer.js b/assets/js/api-search/src/reducer.js index 59f6f42806..d7133c82f7 100644 --- a/assets/js/api-search/src/reducer.js +++ b/assets/js/api-search/src/reducer.js @@ -22,6 +22,12 @@ export default (state, action) => { break; } + case 'CLEAR_RESULTS': { + newState.aggregations = {}; + newState.searchResults = []; + newState.totalResults = 0; + break; + } case 'SEARCH': { newState.args = { ...newState.args, ...action.args, offset: 0 }; newState.isOn = true; diff --git a/assets/js/woocommerce/admin/orders/app/components/listbox.js b/assets/js/woocommerce/admin/orders/app/components/listbox.js new file mode 100644 index 0000000000..579fd186f5 --- /dev/null +++ b/assets/js/woocommerce/admin/orders/app/components/listbox.js @@ -0,0 +1,207 @@ +/** + * WordPress depdendencies. + */ +import { ReactElement, useCallback, useEffect, useRef, useState } from '@wordpress/element'; + +/** + * Listbox component. + * + * @param {object} props Component props. + * @param {Array} props.children Component children. + * @param {string} props.id Element ID. + * @param {Element} props.input Owning input element. + * @param {string} props.label Element accessible label. + * @param {Function} props.onSelect Selection handler. + * @returns {ReactElement} Rendered component. + */ +export default ({ children, id, input, label, onSelect }) => { + const [isExpanded, setIsExpanded] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(false); + + /** + * Use refs to keep track of variables we need in our callbacks without + * using them as dependencies. + * + * Using the original variables of dependencies creates a chain of + * dependencies that causes the initialization effect to run on every + * render, causing events to be repeatedly bound and unbound from the input + * element. + */ + const childrenRef = useRef(children); + const selectedIndexRef = useRef(selectedIndex); + + childrenRef.current = children; + selectedIndexRef.current = selectedIndex; + + /** + * Get the index of the next option. + * + * @param {number} selectedIndex Currently selected index. + * @param {Array} children Options. + * @returns {null|number} Index of the next option. + */ + const getNextIndex = useCallback((selectedIndex, children) => { + if (selectedIndex === false) { + return 0; + } + + const nextIndex = selectedIndex + 1; + + return children?.[nextIndex] ? nextIndex : false; + }, []); + + /** + * Get the index of the previous option. + * + * @param {number} selectedIndex Currently selected index. + * @param {Array} children Options. + * @returns {null|number} Index of the previous option. + */ + const getPreviousIndex = useCallback((selectedIndex, children) => { + const lastIndex = children.length - 1; + + if (selectedIndex === false) { + return lastIndex; + } + + const previousIndex = selectedIndex - 1; + + return children?.[previousIndex] ? previousIndex : false; + }, []); + + /** + * Callback for input focus event. + * + * @param {Event} event Focus event. + */ + const onFocus = useCallback(() => { + setIsExpanded(!!childrenRef.current.length); + }, []); + + /** + * Callback for parent focusout event. + * + * Monitors changes in focus and expands the listbox if focus is within + * the parent container and there are results. + * + * @param {Event} event Focusout event. + */ + const onFocusOut = useCallback((event) => { + setIsExpanded( + event.currentTarget.contains(event.relatedTarget) && !!childrenRef.current.length, + ); + }, []); + + /** + * Handle key presses. + * + * @param {Event} event Key down event. + */ + const onKeyDown = useCallback( + (event) => { + const nextIndex = getNextIndex(selectedIndexRef.current, childrenRef.current); + const previousIndex = getPreviousIndex(selectedIndexRef.current, childrenRef.current); + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setSelectedIndex(nextIndex); + selectedIndexRef.current = nextIndex; + break; + case 'ArrowUp': + event.preventDefault(); + setSelectedIndex(previousIndex); + selectedIndexRef.current = previousIndex; + break; + case 'Enter': + if (selectedIndexRef.current === false) { + return; + } + + event.preventDefault(); + onSelect(selectedIndexRef.current); + + break; + default: + break; + } + }, + [getNextIndex, getPreviousIndex, onSelect], + ); + + /** + * Handle new options being available for the list. + */ + const handleChildren = () => { + setSelectedIndex(false); + setIsExpanded(!!children.length); + }; + + /** + * Handle changes to the expanded state. + */ + const handleExpanded = () => { + input.setAttribute('aria-expanded', isExpanded); + }; + + /** + * Handle initialization. + * + * @returns {Function} Cleanup function. + */ + const handleInit = () => { + input.setAttribute('aria-autocomplete', 'list'); + input.setAttribute('aria-haspopup', true); + input.setAttribute('aria-owns', id); + input.setAttribute('role', 'combobox'); + input.addEventListener('focus', onFocus); + input.addEventListener('keydown', onKeyDown); + input.parentElement.addEventListener('focusout', onFocusOut); + + return () => { + input.removeAttribute('aria-autocomplete'); + input.removeAttribute('aria-haspopup'); + input.removeAttribute('aria-owns'); + input.removeAttribute('role'); + input.removeEventListener('focus', onFocus); + input.removeEventListener('keydown', onKeyDown); + input.parentElement.removeEventListener('focusout', onFocusOut); + }; + }; + + /** + * Effects. + */ + useEffect(handleChildren, [children]); + useEffect(handleExpanded, [input, isExpanded]); + useEffect(handleInit, [id, input, onFocus, onFocusOut, onKeyDown]); + + return ( + + ); +}; diff --git a/assets/js/woocommerce/admin/orders/app/components/shop-order.js b/assets/js/woocommerce/admin/orders/app/components/shop-order.js new file mode 100644 index 0000000000..cf25514c9c --- /dev/null +++ b/assets/js/woocommerce/admin/orders/app/components/shop-order.js @@ -0,0 +1,70 @@ +/** + * WordPress dependencies. + */ +import { date, dateI18n } from '@wordpress/date'; +import { WPElement } from '@wordpress/element'; +import { _n, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies. + */ +import { dateFormat, statusLabels, timeFormat } from '../../config'; + +/** + * Shop order component. + * + * @param {object} props Component props. + * @param {string} props.emailAddress Billing email address. + * @param {string} props.firstName Billing first name. + * @param {string} props.itemCount Order item count. + * @param {string} props.lastName Billing last name. + * @param {string} props.orderNumber Order number. + * @param {string} props.orderDate Order date in GMT. + * @param {string} props.orderStatus Order status. + * @returns {WPElement} Rendered component. + */ +export default ({ + emailAddress, + firstName, + itemCount, + lastName, + orderDate, + orderNumber, + orderStatus, +}) => { + const formattedDate = dateI18n(dateFormat, orderDate, 'GMT'); + const formattedDateTime = date('c', orderDate, 'GMT'); + const formattedTime = dateI18n(timeFormat, orderDate, 'GMT'); + const statusClass = `status-${orderStatus.substring(3)}`; + const statusLabel = statusLabels[orderStatus]; + + return ( +
+

+ + {`#${orderNumber}`} {firstName} {lastName} + + {emailAddress ? ({emailAddress}) : ''} +

+ +
+ ); +}; diff --git a/assets/js/woocommerce/admin/orders/app/index.js b/assets/js/woocommerce/admin/orders/app/index.js new file mode 100644 index 0000000000..ca57ab3664 --- /dev/null +++ b/assets/js/woocommerce/admin/orders/app/index.js @@ -0,0 +1,121 @@ +/** + * WordPress dependencies. + */ +import { useCallback, useEffect, useRef, WPElement } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies. + */ +import { useApiSearch } from '../../../../api-search'; +import { useDebounce } from '../hooks'; +import Listbox from './components/listbox'; +import ShopOrder from './components/shop-order'; + +/** + * Autosuggest app. + * + * @param {object} props Component props. + * @param {string} props.adminUrl Admin URL. + * @param {string} props.input Input element. + * @returns {WPElement} Rendered component. + */ +export default ({ adminUrl, input }) => { + const { clearResults, searchFor, searchResults } = useApiSearch(); + + const searchResultsRef = useRef(searchResults); + + searchResultsRef.current = searchResults; + + /** + * Selection event handler. + * + * @param {number} index Selected option index. + */ + const onSelect = useCallback( + (index) => { + const { post_id } = searchResultsRef.current[index]._source; + + window.location = `${adminUrl}?post=${post_id}&action=edit`; + }, + [adminUrl], + ); + + /** + * Dispatch the change, with debouncing. + */ + const dispatchInput = useDebounce((value) => { + if (value) { + searchFor(value); + } else { + clearResults(); + } + }, 300); + + /** + * Callback for input keyup event. + * + * @param {Event} event keyupEvent + */ + const onInput = useCallback( + (event) => { + dispatchInput(event.target.value); + }, + [dispatchInput], + ); + + /** + * Handle initialization. + * + * @returns {Function} Cleanup function. + */ + const handleInit = () => { + input.addEventListener('input', onInput); + + return () => { + input.removeEventListener('input', onInput); + }; + }; + + /** + * Effects. + */ + useEffect(handleInit, [input, onInput]); + + return ( + + {searchResults.map((option) => { + const { + meta: { + _billing_email: [{ value: billing_email } = {}] = [], + _billing_first_name: [{ value: billing_first_name } = {}] = [], + _billing_last_name: [{ value: billing_last_name } = {}] = [], + _items: [{ value: items } = {}] = [], + }, + post_date_gmt, + post_id, + post_status, + } = option._source; + + return ( + + ); + })} + + ); +}; diff --git a/assets/js/woocommerce/admin/orders/config.js b/assets/js/woocommerce/admin/orders/config.js new file mode 100644 index 0000000000..e526c128c9 --- /dev/null +++ b/assets/js/woocommerce/admin/orders/config.js @@ -0,0 +1,24 @@ +/** + * Window dependencies. + */ +const { + adminUrl, + apiEndpoint, + apiHost, + argsSchema, + authorization, + dateFormat, + statusLabels, + timeFormat, +} = window.epWooCommerceAdminOrders; + +export { + adminUrl, + apiEndpoint, + apiHost, + argsSchema, + authorization, + dateFormat, + statusLabels, + timeFormat, +}; diff --git a/assets/js/woocommerce/admin/orders/hooks.js b/assets/js/woocommerce/admin/orders/hooks.js new file mode 100644 index 0000000000..236d63653d --- /dev/null +++ b/assets/js/woocommerce/admin/orders/hooks.js @@ -0,0 +1,27 @@ +/** + * WordPress dependencies. + */ +import { useCallback, useRef } from '@wordpress/element'; + +/** + * Get debounced version of a function that only runs a given ammount of time + * after the last time it was run. + * + * @param {Function} callback Function to debounce. + * @param {number} delay Milliseconds to delay. + * @returns {Function} Debounced function. + */ +export const useDebounce = (callback, delay) => { + const timeout = useRef(null); + + return useCallback( + (...args) => { + window.clearTimeout(timeout.current); + + timeout.current = window.setTimeout(() => { + callback(...args); + }, delay); + }, + [callback, delay], + ); +}; diff --git a/assets/js/woocommerce/admin/orders/index.js b/assets/js/woocommerce/admin/orders/index.js new file mode 100644 index 0000000000..e326fa3982 --- /dev/null +++ b/assets/js/woocommerce/admin/orders/index.js @@ -0,0 +1,58 @@ +/** + * WordPress dependencies. + */ +import domReady from '@wordpress/dom-ready'; +import { render } from '@wordpress/element'; + +/** + * Internal dependencies. + */ +import { ApiSearchProvider } from '../../../api-search'; +import App from './app'; +import { + adminUrl, + apiEndpoint, + apiHost, + argsSchema, + authorization, + dateFormat, + statusLabels, + timeFormat, +} from './config'; + +/** + * Initialize. + */ +const init = () => { + const form = document.getElementById('posts-filter'); + const input = form.s; + + if (!input) { + return; + } + + const app = document.createElement('div'); + + input.parentElement.appendChild(app); + + render( + + + , + app, + ); +}; + +domReady(init); diff --git a/includes/classes/Feature/WooCommerce/Orders.php b/includes/classes/Feature/WooCommerce/Orders.php new file mode 100644 index 0000000000..11130b4b50 --- /dev/null +++ b/includes/classes/Feature/WooCommerce/Orders.php @@ -0,0 +1,386 @@ +index = Indexables::factory()->get( 'post' )->get_index_name(); + } + + /** + * Setup feature functionality. + * + * @return void + */ + public function setup() { + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_assets' ] ); + 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' ] ); + } + + /** + * Get the endpoint for WooCommerce Orders search. + * + * @return string WooCommerce orders search endpoint. + */ + public function get_search_endpoint() { + /** + * Filters the WooCommerce Orders search endpoint. + * + * @since 4.5.0 + * @hook ep_woocommerce_orders_search_endpoint + * @param {string} $endpoint Endpoint path. + * @param {string} $index Elasticsearch index. + */ + return apply_filters( 'ep_woocommerce_orders_search_endpoint', "api/v1/search/orders/{$this->index}", $this->index ); + } + + /** + * Get the endpoint for the WooCommerce Orders search template. + * + * @return string WooCommerce Orders search template endpoint. + */ + public function get_template_endpoint() { + /** + * Filters the WooCommerce Orders search template API endpoint. + * + * @since 4.5.0 + * @hook ep_woocommerce_orders_template_endpoint + * @param {string} $endpoint Endpoint path. + * @param {string} $index Elasticsearch index. + * @returns {string} Search template API endpoint. + */ + return apply_filters( 'ep_woocommerce_orders_template_endpoint', "api/v1/search/orders/{$this->index}/template", $this->index ); + } + + /** + * Get the endpoint for temporary tokens. + * + * @return string Temporary tokens endpoint. + */ + public function get_tokens_endpoint() { + /** + * Filters the WooCommerce Orders search template API endpoint. + * + * @since 4.5.0 + * @hook ep_tokens_endpoint + * @param {string} $endpoint Endpoint path. + * @returns {string} Search template API endpoint. + */ + return apply_filters( 'ep_tokens_endpoint', 'api/v1/token' ); + } + + /** + * Enqueue admin assets. + * + * @param string $hook_suffix The current admin page. + */ + public function enqueue_admin_assets( $hook_suffix ) { + if ( 'edit.php' !== $hook_suffix ) { + return; + } + + if ( ! isset( $_GET['post_type'] ) || 'shop_order' !== $_GET['post_type'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return; + } + + $temporary_token = $this->get_temporary_token(); + + if ( ! $temporary_token ) { + return; + } + + wp_enqueue_style( + 'elasticpress-woocommerce-admin-orders', + EP_URL . 'dist/css/woocommerce-admin-orders-styles.css', + Utils\get_asset_info( 'woocommerce-admin-orders-styles', 'dependencies' ), + Utils\get_asset_info( 'woocommerce-admin-orders-styles', 'version' ) + ); + + wp_enqueue_script( + 'elasticpress-woocommerce-admin-orders', + EP_URL . 'dist/js/woocommerce-admin-orders-script.js', + Utils\get_asset_info( 'woocommerce-admin-orders-script', 'dependencies' ), + Utils\get_asset_info( 'woocommerce-admin-orders-script', 'version' ), + true + ); + + wp_set_script_translations( 'elasticpress-woocommerce-admin-orders', 'elasticpress' ); + + $api_endpoint = $this->get_search_endpoint(); + $api_host = Utils\get_host(); + + wp_localize_script( + 'elasticpress-woocommerce-admin-orders', + 'epWooCommerceAdminOrders', + array( + 'adminUrl' => admin_url( 'post.php' ), + 'apiEndpoint' => $api_endpoint, + 'apiHost' => ( 0 !== strpos( $api_endpoint, 'http' ) ) ? esc_url_raw( $api_host ) : '', + 'authorization' => "Basic $temporary_token", + 'argsSchema' => $this->get_args_schema(), + 'dateFormat' => wc_date_format(), + 'statusLabels' => wc_get_order_statuses(), + 'timeFormat' => wc_time_format(), + ) + ); + } + + /** + * Save or delete the search template on ElasticPress.io based on whether + * the WooCommerce feature is being activated or deactivated. + * + * @param string $feature Feature slug + * @param array $settings Feature settings + * @param array $data Feature activation data + * + * @return void + */ + public function after_update_feature( $feature, $settings, $data ) { + if ( 'woocommerce' !== $featured ) { + return; + } + + if ( true === $data['active'] ) { + $this->epio_save_search_template(); + } else { + $this->epio_delete_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.5.0 + * @hook ep_woocommerce_orders_template_saved + * @param {string} $template The search template (JSON). + * @param {string} $index Index name. + */ + do_action( 'ep_woocommerce_orders_template_saved', $template, $this->index ); + } + + /** + * Delete the search template from ElasticPress.io. + * + * @return void + */ + 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.5.0 + * @hook ep_woocommerce_orders_template_deleted + * @param {string} $index Index name. + */ + do_action( 'ep_woocommerce_orders_template_deleted', $this->index ); + } + + /** + * Get the saved search template from ElasticPress.io. + * + * @return string|WP_Error Search template if found, WP_Error on error. + */ + 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() { + $order_statuses = wc_get_order_statuses(); + + add_filter( 'ep_bypass_exclusion_from_search', '__return_true', 10 ); + 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_order_search_template' => true, + 'post_status' => array_keys( $order_statuses ), + 'post_type' => 'shop_order', + 's' => '{{ep_placeholder}}', + ) + ); + + remove_filter( 'ep_bypass_exclusion_from_search', '__return_true', 10 ); + 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 ); + + return $this->search_template; + } + + /** + * Return true if a given feature is supported by WooCommerce Orders. + * + * 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 WooCommerce + * Order search. + */ + public function is_integrated_request( $is_integrated, $context ) { + $supported_contexts = [ + 'search', + '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 ); + } + + /** + * Get schema for search args. + * + * @return array Search args schema. + */ + public function get_args_schema() { + $args = array( + 'customer' => array( + 'type' => 'number', + ), + 'm' => array( + 'type' => 'string', + ), + 'offset' => array( + 'type' => 'number', + 'default' => 0, + ), + 'per_page' => array( + 'type' => 'number', + 'default' => 6, + ), + 'search' => array( + 'type' => 'string', + 'default' => '', + ), + ); + + return $args; + } + + /** + * Get temporary token. + * + * @return string|false Temporary token, or false on failure. + */ + public function get_temporary_token() { + $user_id = get_current_user_id(); + + if ( ! user_can( $user_id, 'edit_others_shop_orders' ) ) { + return false; + } + + $temporary_token = get_user_meta( $user_id, 'ep_temporary_token', true ); + + if ( $temporary_token ) { + return $temporary_token; + } + + $endpoint = $this->get_tokens_endpoint(); + $response = Elasticsearch::factory()->remote_request( $endpoint, [ 'method' => 'POST' ] ); + + if ( is_wp_error( $response ) ) { + return false; + } + + $response = wp_remote_retrieve_body( $response ); + $response = json_decode( $response ); + + $token = base64_encode( "$response->username:$response->clear_password" ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + + update_user_meta( $user_id, 'ep_temporary_token', $token ); + + return $token; + } +} diff --git a/package.json b/package.json index 4e0008851e..7f7a452193 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "stats-script": "./assets/js/stats.js", "status-report-script": "./assets/js/status-report/index.js", "synonyms-script": "./assets/js/synonyms/index.js", + "woocommerce-admin-orders-script": "./assets/js/woocommerce/admin/orders/index.js", "admin-script": "./assets/js/admin.js", "weighting-script": "./assets/js/weighting.js", "search-editor-script": "./assets/js/search/editor/index.js", @@ -81,7 +82,8 @@ "related-posts-block-styles": "./assets/css/related-posts-block.css", "status-report-styles": "./assets/css/status-report.css", "sync-styles": "./assets/css/sync.css", - "synonyms-styles": "./assets/css/synonyms.css" + "synonyms-styles": "./assets/css/synonyms.css", + "woocommerce-admin-orders-styles": "./assets/css/woocommerce/admin/orders.css" }, "wpDependencyExternals": true },