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 (
+
+ {children.map((child, index) => (
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events
+ - onSelect(index)}
+ onMouseEnter={() => {
+ setSelectedIndex(index);
+ selectedIndexRef.current = index;
+ }}
+ role="option"
+ tabIndex="-1"
+ >
+ {child}
+
+ ))}
+
+ );
+};
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
},