PluginProbe
ElasticPress / 4.4.1
ElasticPress v4.4.1
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.4.1, at assets/js/autosuggest/index.js

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