PluginProbe
ElasticPress / 4.5.0
ElasticPress v4.5.0
5.3.5 5.3.4 3.6.5 3.6.6 4.0.0 4.0.1 4.1.0 4.2.0 4.2.1 4.2.2 4.3.0 4.3.1 4.4.0 4.4.1 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.7.0 4.7.1 4.7.2 5.0.0 5.0.1 5.0.2 All 108 releases
elasticpress / assets / js / autosuggest / index.js

index.js in ElasticPress 4.5.0, at assets/js/autosuggest/index.js

846 lines 21.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /* eslint-disable camelcase, no-underscore-dangle, no-use-before-define */
2
3 /**
4 * WordPress dependencies.
5 */
6 import { applyFilters } from '@wordpress/hooks';
7
8 /**
9 * Internal dependencies.
10 */
11 import './back-compat';
12 import {
13 findAncestorByClass,
14 escapeDoubleQuotes,
15 replaceGlobally,
16 debounce,
17 domReady,
18 generateRequestId,
19 } from '../utils/helpers';
20
21 const { epas } = window;
22
23 // Ensure we have an endpoint URL, or
24 // else this shouldn't happen
25 if (epas.endpointUrl && epas.endpointUrl !== '') {
26 init();
27
28 // Publically expose API
29 window.epasAPI = {
30 hideAutosuggestBox,
31 updateAutosuggestBox,
32 esSearch,
33 buildSearchQuery,
34 };
35 }
36
37 /**
38 * Submit the search form
39 *
40 * @param {Node} input - input element
41 */
42 function submitSearchForm(input) {
43 input.closest('form').submit();
44 }
45
46 /**
47 * Set the expanded aria state on the input
48 *
49 * @param {boolean} haveOptions - whether or not the autosuggest list contains results
50 * @param {Node} input - search input
51 */
52 function toggleInputAria(haveOptions, input) {
53 input.setAttribute('aria-expanded', haveOptions);
54 }
55
56 /**
57 * Set the active descendant aria attribute input
58 *
59 * @param {string} id - id of the currently selected element
60 * @param {Node} input - search input
61 */
62 function setInputActiveDescendant(id, input) {
63 input.setAttribute('aria-activedescendant', id);
64 }
65
66 /**
67 * Take selected item and fill the search input
68 *
69 * @param {Node} input - input element
70 * @param {string} text - new input value
71 */
72 function selectAutosuggestItem(input, text) {
73 input.value = text; // eslint-disable-line no-param-reassign
74 }
75
76 /**
77 * Fires events when autosuggest results are clicked,
78 * and if GA tracking is activated
79 *
80 * @param {object} detail - value to pass on to the Custom Event
81 */
82 function triggerAutosuggestEvent(detail) {
83 const event = new CustomEvent('ep-autosuggest-click', { detail });
84 window.dispatchEvent(event);
85
86 if (
87 detail.searchTerm &&
88 parseInt(epas.triggerAnalytics, 10) === 1 &&
89 typeof gtag === 'function'
90 ) {
91 const action = `click - ${detail.searchTerm}`;
92 // eslint-disable-next-line no-undef
93 gtag('event', action, {
94 event_category: 'EP :: Autosuggest',
95 event_label: detail.url,
96 transport_type: 'beacon',
97 });
98 }
99 }
100
101 /**
102 * Navigate to the selected item, and provides
103 * event hook for JS customizations, like GA
104 *
105 * @param {string} searchTerm - user defined search term
106 * @param {string} url - post url from dataset in search result
107 */
108 function goToAutosuggestItem(searchTerm, url) {
109 const detail = {
110 searchTerm,
111 url,
112 };
113
114 triggerAutosuggestEvent(detail);
115 window.location.href = url;
116 }
117
118 /**
119 * Respond to an item selection based on the predefined behavior.
120 * If epas.action is set to "navigate" (the default), redirects the browser to the URL of the selected item
121 * If epas.action is set to any other value (such as "search"), fill in the value and perform the search
122 *
123 * @param {Node} input - search input
124 * @param {Node} element - search term result item
125 * @returns {Function} calls the submitSearchForm function
126 */
127 function selectItem(input, element) {
128 if (epas.action === 'navigate') {
129 return goToAutosuggestItem(input.value, element.dataset.url);
130 }
131
132 selectAutosuggestItem(input, element.innerText);
133 return submitSearchForm(input);
134 }
135
136 /**
137 * Build the search query from the search text - the query is generated in PHP
138 * and passed into the front end as window.epas = { "query...
139 *
140 * @returns {string} json string
141 */
142 function getJsonQuery() {
143 if (typeof window.epas === 'undefined') {
144 const error = 'No epas object defined';
145
146 // eslint-disable-next-line no-console
147 console.warn(error);
148 return { error };
149 }
150
151 return window.epas;
152 }
153
154 /**
155 * Build the search query from the search text
156 *
157 * @param {string} searchText - user search string
158 * @param {string} placeholder - placeholder text to replace
159 * @param {object} options - Autosuggest settings
160 * @param {string} options.query - JSON query string to pass to ElasticSearch
161 * @returns {string} json representation of search query
162 */
163 function buildSearchQuery(searchText, placeholder, { query }) {
164 const newQuery = replaceGlobally(query, placeholder, searchText);
165 return newQuery;
166 }
167
168 /**
169 * Build the ajax request
170 *
171 * @param {string} query - json string
172 * @param {string} searchTerm - user search term
173 * @returns {object} AJAX object request
174 */
175 async function esSearch(query, searchTerm) {
176 const fetchConfig = {
177 body: query,
178 method: 'POST',
179 mode: 'cors',
180 headers: {
181 'Content-Type': 'application/json; charset=utf-8',
182 },
183 };
184
185 if (epas?.http_headers && typeof epas.http_headers === 'object') {
186 Object.keys(epas.http_headers).forEach((name) => {
187 fetchConfig.headers[name] = epas.http_headers[name];
188 });
189 }
190
191 // only applies headers if using ep.io endpoint
192 if (epas.addSearchTermHeader) {
193 fetchConfig.headers['EP-Search-Term'] = encodeURI(searchTerm);
194 }
195
196 // only add a request ID if using ep.io endpoint
197 const requestId = generateRequestId(epas?.requestIdBase || '');
198 if (requestId) {
199 fetchConfig.headers['X-ElasticPress-Request-ID'] = requestId;
200 }
201
202 try {
203 const response = await fetch(epas.endpointUrl, fetchConfig);
204
205 if (!response.ok) {
206 throw Error(response.statusText);
207 }
208
209 const data = await response.json();
210
211 /**
212 * Filter the Elasticsearch response data used for Autosuggest.
213 *
214 * @filter ep.Autosuggest.data
215 * @since 4.3.1
216 *
217 * @param {object} data Response data.
218 * @param {string} searchTerm Search term.
219 * @returns {object} Response data.
220 */
221 return applyFilters('ep.Autosuggest.data', data, searchTerm);
222 } catch (error) {
223 // eslint-disable-next-line no-console
224 console.error(error);
225 return error;
226 }
227 }
228
229 /**
230 * Update the auto suggest box with new options or hide if none
231 *
232 * @param {Array} options - search results
233 * @param {string} input - search string
234 * @returns {boolean} return true
235 */
236 function updateAutosuggestBox(options, input) {
237 let listHTML = '';
238
239 // get the search term for use later on
240 const { value: searchText } = input;
241 const container = findAncestorByClass(input, 'ep-autosuggest-container');
242 const resultsContainer = container.querySelector('.ep-autosuggest');
243 const suggestList = resultsContainer.querySelector('.autosuggest-list');
244
245 // empty the the list of all child nodes
246 while (suggestList.firstChild) {
247 suggestList.removeChild(suggestList.firstChild);
248 }
249
250 if (options.length > 0) {
251 resultsContainer.style = 'display: block;';
252 } else {
253 resultsContainer.style = 'display: none;';
254 }
255
256 // anticipating the future... a setting where we configure
257 // a limit of results to show, and optionally append a
258 // link to "all results" or something of that nature
259 const resultsLimit = options.length;
260
261 // create markup for list items
262 // eslint-disable-next-line
263 for ( let index = 0; resultsLimit > index; ++index ) {
264 const option = options[index];
265 const text = option._source.post_title;
266 const url = option._source.permalink;
267 const escapedText = escapeDoubleQuotes(text);
268
269 const searchParts = searchText.trim().split(' ');
270 let resultsText = escapedText;
271
272 if (epas.highlightingEnabled) {
273 // uses some regex magic to match upper/lower/capital case
274 const regex = new RegExp(`\\b(${searchParts.join('|')})`, 'gi');
275 resultsText = resultsText.replace(
276 regex,
277 (word) =>
278 `<${epas.highlightingTag} class="${epas.highlightingClass} ep-autosuggest-highlight">${word}</${epas.highlightingTag}>`,
279 );
280 }
281
282 let itemHTML = `<li class="autosuggest-item" role="option" aria-selected="false" id="autosuggest-option-${index}">
283 <a href="${url}" class="autosuggest-link" data-search="${escapedText}" data-url="${url}" tabindex="-1">
284 ${resultsText}
285 </a>
286 </li>`;
287
288 /**
289 * Filter the HTML for an Autosuggest suggestion.
290 *
291 * @filter ep.Autosuggest.itemHTML
292 * @since 4.3.1
293 *
294 * @param {string} itemHTML Item HTML.
295 * @param {object} option Elasticsearch record for suggestion.
296 * @param {number} index Suggestion index.
297 * @param {string} searchText Search term.
298 * @returns {string} Item HTML.
299 */
300 itemHTML = applyFilters('ep.Autosuggest.itemHTML', itemHTML, option, index, searchText);
301
302 listHTML += itemHTML;
303 }
304
305 /**
306 * Filter the HTML for the list of Autosuggest suggestions.
307 *
308 * @filter ep.Autosuggest.listHTML
309 * @since 4.3.1
310 *
311 * @param {string} listHTML List HTML.
312 * @param {object[]} options Elasticsearch records for suggestions.
313 * @param {Element} input Input element that triggered Autosuggest.
314 * @returns {string} List HTML.
315 */
316 listHTML = applyFilters('ep.Autosuggest.listHTML', listHTML, options, input);
317
318 // append list items to the list
319 suggestList.innerHTML = listHTML;
320
321 suggestList.addEventListener('click', (event) => {
322 event.preventDefault();
323
324 const element = event.target.closest('.autosuggest-link');
325
326 if (suggestList.contains(element)) {
327 selectItem(input, element);
328 }
329 });
330
331 return true;
332 }
333
334 /**
335 * Hide the auto suggest box
336 *
337 * @returns {boolean} returns true
338 */
339 function hideAutosuggestBox() {
340 const lists = document.querySelectorAll('.autosuggest-list');
341 const containers = document.querySelectorAll('.ep-autosuggest');
342
343 // empty all EP results lists
344 lists.forEach((list) => {
345 while (list.firstChild) {
346 list.removeChild(list.firstChild);
347 }
348 });
349
350 // hide all EP results containers
351 containers.forEach((container) => {
352 // eslint-disable-next-line
353 container.style = 'display: none;';
354 });
355
356 return true;
357 }
358
359 /**
360 * Checks for any manually ordered posts and puts them in the correct place
361 *
362 * @param {Array} hits - ES results
363 * @param {string} searchTerm - user search term
364 * @returns {object} formatted hits
365 */
366 function checkForOrderedPosts(hits, searchTerm) {
367 const toInsert = {};
368 const taxName = 'ep_custom_result';
369 const lowerCaseSearchTerm = searchTerm.toLowerCase();
370
371 const filteredHits = hits.filter((hit) => {
372 // Should we retain this hit in its current position?
373 let retain = true;
374
375 if (undefined !== hit._source.terms && undefined !== hit._source.terms[taxName]) {
376 hit._source.terms[taxName].forEach((currentTerm) => {
377 if (currentTerm.name.toLowerCase() === lowerCaseSearchTerm) {
378 toInsert[currentTerm.term_order] = hit;
379
380 retain = false;
381 }
382 });
383 }
384
385 return retain;
386 });
387
388 const orderedInserts = {};
389
390 Object.keys(toInsert)
391 .sort()
392 .forEach((key) => {
393 orderedInserts[key] = toInsert[key];
394 });
395
396 if (Object.keys(orderedInserts).length > 0) {
397 Object.keys(orderedInserts).forEach((key) => {
398 const insertItem = orderedInserts[key];
399
400 filteredHits.splice(key - 1, 0, insertItem);
401 });
402 }
403
404 return filteredHits;
405 }
406
407 /**
408 * Add class to the form element while suggestions are being loaded
409 *
410 * @param {boolean} isLoading - whether suggestions are loading
411 * @param {Node} input - search input field
412 */
413 function setFormIsLoading(isLoading, input) {
414 const form = input.closest('form');
415
416 if (isLoading) {
417 form.classList.add('is-loading');
418 } else {
419 form.classList.remove('is-loading');
420 }
421 }
422
423 /**
424 * init method called if the epas endpoint is defined
425 */
426 function init() {
427 const selectors = [epas.defaultSelectors, epas.selector].filter(Boolean).join(',');
428
429 if (!selectors) {
430 return;
431 }
432
433 // For the Autosuggest element that will be cloned.
434 let autosuggestElement;
435
436 // to be used by the handleUpDown function
437 // to keep track of the currently selected result
438 let currentIndex;
439
440 // these are the keycodes we listen for in handleUpDown,
441 // and in handleKeyup
442 const keyCodes = [
443 38, // up
444 40, // down
445 13, // enter
446 ];
447
448 /**
449 * Handles keyup event on the search input
450 *
451 * @param {event} event - keyup event
452 */
453 const handleUpDown = (event) => {
454 if (!keyCodes.includes(event.keyCode)) {
455 return;
456 }
457
458 const input = event.target;
459 const container = findAncestorByClass(input, 'ep-autosuggest-container');
460 const suggestList = container.querySelector('.autosuggest-list');
461 const results = suggestList.children;
462
463 /**
464 * helper function to get the currently selected result
465 *
466 * @returns {number} index of the selected search result
467 */
468 const getSelectedResultIndex = () => {
469 const resultsArr = Array.from(results);
470 return resultsArr.findIndex((result) => result.classList.contains('selected'));
471 };
472
473 /**
474 * helper function to deselect results
475 */
476 const deSelectResults = () => {
477 Array.from(results).forEach((result) => {
478 result.classList.remove('selected');
479 result.setAttribute('aria-selected', 'false');
480 });
481 };
482
483 /**
484 * helper function to selected the next result
485 */
486 const selectNextResult = () => {
487 if (currentIndex >= 0) {
488 const el = results[currentIndex];
489 el.classList.add('selected');
490 el.setAttribute('aria-selected', 'true');
491 setInputActiveDescendant(el.id, input);
492 }
493 };
494
495 // select next or previous based on keyCode
496 // if enter, navigate to that element
497 switch (event.keyCode) {
498 case 38: // Up
499 // don't go less than the 0th index
500 currentIndex = currentIndex - 1 >= 0 ? currentIndex - 1 : 0;
501 deSelectResults();
502 break;
503 case 40: // Down
504 if (typeof currentIndex === 'undefined') {
505 // index is not yet defined, so let's
506 // start with the first one
507 currentIndex = 0;
508 } else {
509 const current = getSelectedResultIndex();
510
511 // check for existence of next result
512 if (results[current + 1]) {
513 currentIndex = current + 1;
514 deSelectResults();
515 }
516 }
517 break;
518 case 13: // Enter
519 if (results[currentIndex]?.classList.contains('selected')) {
520 // navigate to the item defined in the span's data-url attribute
521 selectItem(input, results[currentIndex].querySelector('.autosuggest-link'));
522 }
523 break;
524 default:
525 // No item selected
526 break;
527 }
528
529 // only check next element if up and down key pressed
530 if (results[currentIndex] && results[currentIndex].classList.contains('autosuggest-item')) {
531 selectNextResult();
532 } else {
533 deSelectResults();
534 }
535
536 // keep cursor from heading back to the beginning in the input
537 if (event.keyCode === 38) {
538 // return false;
539 event.preventDefault();
540 }
541 };
542
543 /**
544 * Get the searched post types from the search form.
545 *
546 * @param {HTMLFormElement} form - form containing the search input field
547 * @returns {Array} - post types
548 * @since 3.6.0
549 */
550 function getPostTypesFromForm(form) {
551 const data = new FormData(form);
552
553 if (data.has('post_type')) {
554 return data.getAll('post_type').slice(-1);
555 }
556
557 if (data.has('post_type[]')) {
558 return data.getAll('post_type[]');
559 }
560
561 return [];
562 }
563
564 /**
565 * Calls the ajax request, and outputs the results.
566 * Called by the handleKeyup callback, debounced.
567 *
568 * @param {Node} input - search input field
569 */
570 const fetchResults = async (input) => {
571 // retrieves the PHP-genereated query to pass to ElasticSearch
572 const queryJSON = getJsonQuery();
573
574 if (queryJSON.error) {
575 return;
576 }
577
578 const searchText = input.value;
579 const placeholder = 'ep_autosuggest_placeholder';
580 const postTypes = getPostTypesFromForm(input.form);
581
582 if (searchText.length >= 2) {
583 setFormIsLoading(true, input);
584
585 let query = buildSearchQuery(searchText, placeholder, queryJSON);
586
587 query = JSON.parse(query);
588
589 if (postTypes.length > 0) {
590 if (typeof query.post_filter.bool.must !== 'undefined') {
591 query.post_filter.bool.must.push({
592 terms: {
593 'post_type.raw': postTypes,
594 },
595 });
596 }
597 }
598
599 /**
600 * Filter the Elasticsearch query used for Autosuggest.
601 *
602 * @filter ep.Autosuggest.query
603 * @since 4.3.1
604 *
605 * @param {object} query Elasticsearch query.
606 * @param {string} searchText Search term.
607 * @param {Element} input Input element that triggered Autosuggest.
608 * @returns {object} Elasticsearch query.
609 */
610 query = applyFilters('ep.Autosuggest.query', query, searchText, input);
611 query = JSON.stringify(query);
612
613 // fetch the results
614 const response = await esSearch(query, searchText);
615
616 if (response && response._shards && response._shards.successful > 0) {
617 const hits = checkForOrderedPosts(response.hits.hits, searchText);
618
619 if (hits.length === 0) {
620 hideAutosuggestBox();
621 } else {
622 updateAutosuggestBox(hits, input);
623 }
624 } else {
625 hideAutosuggestBox();
626 }
627
628 setFormIsLoading(false, input);
629 } else if (searchText.length === 0) {
630 hideAutosuggestBox();
631 }
632 };
633
634 const debounceFetchResults = debounce(fetchResults, 200);
635
636 /**
637 * Callback for keyup in Autosuggest container.
638 *
639 * Calls a debounced function to get the search results via
640 * ajax request.
641 *
642 * @param {event} event - keyup event
643 */
644 const handleKeyup = (event) => {
645 event.preventDefault();
646 const { target, key, keyCode } = event;
647
648 if (key === 'Escape' || key === 'Esc' || keyCode === 27) {
649 hideAutosuggestBox();
650 toggleInputAria(false, target);
651 setInputActiveDescendant('', target);
652 return;
653 }
654
655 if (keyCodes.includes(keyCode) && target.value !== '') {
656 handleUpDown(event);
657 return;
658 }
659
660 const input = event.target;
661 debounceFetchResults(input);
662 };
663
664 /**
665 * Wrap an element with an autosuggest container.
666 *
667 * @param {Element} element Element to wrap.
668 * @returns {void}
669 */
670 const wrapInAutosuggestContainer = (element) => {
671 const epContainer = document.createElement('div');
672
673 epContainer.classList.add('ep-autosuggest-container');
674
675 element.insertAdjacentElement('afterend', epContainer);
676
677 epContainer.appendChild(element);
678 };
679
680 /**
681 * Insert an autosuggest list after an element.
682 *
683 * @param {Element} previousElement Element to add the autosuggest list after.
684 * @returns {void}
685 */
686 const insertAutosuggestElement = (previousElement) => {
687 if (!autosuggestElement) {
688 autosuggestElement = document.createElement('div');
689 autosuggestElement.classList.add('ep-autosuggest');
690
691 const autosuggestList = document.createElement('ul');
692
693 autosuggestList.classList.add('autosuggest-list');
694 autosuggestList.setAttribute('role', 'listbox');
695
696 autosuggestElement.appendChild(autosuggestList);
697 }
698
699 let element = autosuggestElement.cloneNode(true);
700
701 /**
702 * Filter the Autosuggest container element before it is inserted.
703 *
704 * @filter ep.Autosuggest.element
705 * @since 4.3.1
706 *
707 * @param {Element} element Autosuggest container element.
708 * @param {Element} previousElement Element the container will be inserted after.
709 * @returns {Element} Autosuggest container element.
710 */
711 element = applyFilters('ep.Autosuggest.element', element, previousElement);
712
713 previousElement.insertAdjacentElement('afterend', element);
714 };
715
716 /**
717 * Prepare an input for Autosuggest.
718 *
719 * @param {Element} input Input to prepare.
720 * @returns {void}
721 */
722 const prepareInputForAutosuggest = (input) => {
723 /**
724 * Skip facet widget search fields and instant results.
725 */
726 const ignoredClasses = ['facet-search', 'ep-search-input'];
727 if (ignoredClasses.some((className) => input.classList.contains(className))) {
728 return;
729 }
730
731 /**
732 * Disable autocomplete.
733 */
734 input.setAttribute('autocomplete', 'off');
735
736 /**
737 * We know the markup of the Search block, so we don't need to add a
738 * wrapper.
739 */
740 if (input.classList.contains('wp-block-search__input')) {
741 input.form.classList.add('ep-autosuggest-container');
742 insertAutosuggestElement(input.parentElement);
743 } else {
744 wrapInAutosuggestContainer(input);
745 insertAutosuggestElement(input);
746 }
747
748 /**
749 * Dispatch an event announcing the input has moved.
750 */
751 const event = new CustomEvent('elasticpress.input.moved');
752
753 input.dispatchEvent(event);
754
755 /**
756 * Listen for any events:
757 *
758 * keyup
759 * send them for a query to the Elasticsearch server
760 * handle up and down keys to move between results
761 *
762 * blur
763 * hide the autosuggest box
764 */
765 input.addEventListener('keyup', handleKeyup);
766 input.addEventListener('blur', function () {
767 window.setTimeout(hideAutosuggestBox, 200);
768 });
769 };
770
771 /**
772 * Find inputs within an element and prepare them for Autosuggest.
773 *
774 * @param {Element} element Element to find inputs within.
775 * @returns {void}
776 */
777 const findAndPrepareInputsForAutosuggest = (element) => {
778 const inputs = element.querySelectorAll(selectors);
779
780 if (inputs) {
781 Array.from(inputs).forEach(prepareInputForAutosuggest);
782 }
783 };
784
785 /**
786 * Observe the document for new potential Autosuggest inputs, and add
787 * Autosuggest to any found inputs.
788 *
789 * @returns {void}
790 */
791 const observeDocumentForInputs = () => {
792 const target = document.body;
793 const config = {
794 subtree: true,
795 childList: true,
796 };
797
798 const observer = new MutationObserver((mutations, observer) => {
799 mutations.forEach((mutation) => {
800 Array.from(mutation.addedNodes).forEach((node) => {
801 if (node.nodeType !== Node.ELEMENT_NODE) {
802 return;
803 }
804
805 /**
806 * Adding autosuggest to an input moves it in the DOM,
807 * which would trigger our observer, so we need to
808 * stop observing until it's been prepared.
809 */
810 observer.disconnect();
811
812 /**
813 * If the node is an input, prepare it for Autosuggest if
814 * it matches the selectors, otherwise search the node for
815 * inputs.
816 */
817 if (node.tagName === 'INPUT') {
818 if (node.matches(selectors)) {
819 prepareInputForAutosuggest(node);
820 }
821 } else {
822 findAndPrepareInputsForAutosuggest(node);
823 }
824
825 /**
826 * Resume observing.
827 */
828 observer.observe(target, config);
829 });
830 });
831 });
832
833 observer.observe(target, config);
834 };
835
836 /**
837 * Add autosuggest to any inputs in the document.
838 */
839 findAndPrepareInputsForAutosuggest(document.body);
840
841 /**
842 * When the DOM is ready start observing for new inputs.
843 */
844 domReady(observeDocumentForInputs);
845 }
846