import Hogan from 'hogan.js'; import LunrSearchAdapter from './lunar-search'; import autocomplete from 'autocomplete.js'; import templates from './templates'; import utils from './utils'; import $ from 'autocomplete.js/zepto'; class DocSearch { constructor({ searchDocs, searchIndex, inputSelector, debug = false, baseUrl = '/', queryDataCallback = null, autocompleteOptions = { debug: false, hint: false, autoselect: true, }, transformData = false, queryHook = false, handleSelected = false, enhancedSearchInput = false, layout = 'collumns', }) { this.input = DocSearch.getInputFromSelector(inputSelector); this.queryDataCallback = queryDataCallback || null; const autocompleteOptionsDebug = autocompleteOptions && autocompleteOptions.debug ? autocompleteOptions.debug : false; // eslint-disable-next-line no-param-reassign autocompleteOptions.debug = debug || autocompleteOptionsDebug; this.autocompleteOptions = autocompleteOptions; this.autocompleteOptions.cssClasses = this.autocompleteOptions.cssClasses || {}; this.autocompleteOptions.cssClasses.prefix = this.autocompleteOptions.cssClasses.prefix || 'ds'; const inputAriaLabel = this.input && typeof this.input.attr === 'function' && this.input.attr('aria-label'); this.autocompleteOptions.ariaLabel = this.autocompleteOptions.ariaLabel || inputAriaLabel || 'search input'; this.isSimpleLayout = layout === 'simple'; this.client = new LunrSearchAdapter(searchDocs, searchIndex, baseUrl); if (enhancedSearchInput) { this.input = DocSearch.injectSearchBox(this.input); } this.autocomplete = autocomplete(this.input, autocompleteOptions, [ { source: this.getAutocompleteSource(transformData, queryHook), templates: { suggestion: DocSearch.getSuggestionTemplate(this.isSimpleLayout), footer: templates.footer, empty: DocSearch.getEmptyTemplate(), }, }, ]); const customHandleSelected = handleSelected; this.handleSelected = customHandleSelected || this.handleSelected; // We prevent default link clicking if a custom handleSelected is defined if (customHandleSelected) { $('.algolia-autocomplete').on('click', '.ds-suggestions a', (event) => { event.preventDefault(); }); } this.autocomplete.on('autocomplete:selected', this.handleSelected.bind(null, this.autocomplete.autocomplete)); this.autocomplete.on('autocomplete:shown', this.handleShown.bind(null, this.input)); if (enhancedSearchInput) { DocSearch.bindSearchBoxEvent(); } } static injectSearchBox(input) { input.before(templates.searchBox); const newInput = input.prev().prev().find('input'); input.remove(); return newInput; } static bindSearchBoxEvent() { $('.searchbox [type="reset"]').on('click', function () { $('input#docsearch').focus(); $(this).addClass('hide'); autocomplete.autocomplete.setVal(''); }); $('input#docsearch').on('keyup', () => { const searchbox = document.querySelector('input#docsearch'); const reset = document.querySelector('.searchbox [type="reset"]'); reset.className = 'searchbox__reset'; if (searchbox.value.length === 0) { reset.className += ' hide'; } }); } /** * Returns the matching input from a CSS selector, null if none matches * @function getInputFromSelector * @param {string} selector CSS selector that matches the search * input of the page * @returns {void} */ static getInputFromSelector(selector) { const input = $(selector).filter('input'); return input.length ? $(input[0]) : null; } /** * Returns the `source` method to be passed to autocomplete.js. It will query * the Algolia index and call the callbacks with the formatted hits. * @function getAutocompleteSource * @param {function} transformData An optional function to transform the hits * @param {function} queryHook An optional function to transform the query * @returns {function} Method to be passed as the `source` option of * autocomplete */ getAutocompleteSource(transformData, queryHook) { return (query, callback) => { if (queryHook) { // eslint-disable-next-line no-param-reassign query = queryHook(query) || query; } this.client.search(query).then((hits) => { if (this.queryDataCallback && typeof this.queryDataCallback == 'function') { this.queryDataCallback(hits); } if (transformData) { hits = transformData(hits) || hits; } callback(DocSearch.formatHits(hits)); }); }; } // Given a list of hits returned by the API, will reformat them to be used in // a Hogan template static formatHits(receivedHits) { const clonedHits = utils.deepClone(receivedHits); const hits = clonedHits.map((hit) => { if (hit._highlightResult) { // eslint-disable-next-line no-param-reassign hit._highlightResult = utils.mergeKeyWithParent(hit._highlightResult, 'hierarchy'); } return utils.mergeKeyWithParent(hit, 'hierarchy'); }); // Group hits by category / subcategory let groupedHits = utils.groupBy(hits, 'lvl0'); $.each(groupedHits, (level, collection) => { const groupedHitsByLvl1 = utils.groupBy(collection, 'lvl1'); const flattenedHits = utils.flattenAndFlagFirst(groupedHitsByLvl1, 'isSubCategoryHeader'); groupedHits[level] = flattenedHits; }); groupedHits = utils.flattenAndFlagFirst(groupedHits, 'isCategoryHeader'); // Translate hits into smaller objects to be send to the template return groupedHits.map((hit) => { const url = DocSearch.formatURL(hit); const category = utils.getHighlightedValue(hit, 'lvl0'); const subcategory = utils.getHighlightedValue(hit, 'lvl1') || category; const displayTitle = utils .compact([ utils.getHighlightedValue(hit, 'lvl2') || subcategory, utils.getHighlightedValue(hit, 'lvl3'), utils.getHighlightedValue(hit, 'lvl4'), utils.getHighlightedValue(hit, 'lvl5'), utils.getHighlightedValue(hit, 'lvl6'), ]) .join(''); const text = utils.getSnippetedValue(hit, 'content'); const isTextOrSubcategoryNonEmpty = (subcategory && subcategory !== '') || (displayTitle && displayTitle !== ''); const isLvl1EmptyOrDuplicate = !subcategory || subcategory === '' || subcategory === category; const isLvl2 = displayTitle && displayTitle !== '' && displayTitle !== subcategory; const isLvl1 = !isLvl2 && subcategory && subcategory !== '' && subcategory !== category; const isLvl0 = !isLvl1 && !isLvl2; return { isLvl0, isLvl1, isLvl2, isLvl1EmptyOrDuplicate, isCategoryHeader: hit.isCategoryHeader, isSubCategoryHeader: hit.isSubCategoryHeader, isTextOrSubcategoryNonEmpty, category, subcategory, title: displayTitle, text, url, }; }); } static formatURL(hit) { const { url, anchor } = hit; if (url) { const containsAnchor = url.indexOf('#') !== -1; if (containsAnchor) return url; else if (anchor) return `${hit.url}#${hit.anchor}`; return url; } else if (anchor) return `#${hit.anchor}`; /* eslint-disable */ console.warn('no anchor nor url for : ', JSON.stringify(hit)); /* eslint-enable */ return null; } static getEmptyTemplate() { return (args) => Hogan.compile(templates.empty).render(args); } static getSuggestionTemplate(isSimpleLayout) { const stringTemplate = isSimpleLayout ? templates.suggestionSimple : templates.suggestion; const template = Hogan.compile(stringTemplate); return (suggestion) => template.render(suggestion); } handleSelected(input, event, suggestion, datasetNumber, context = {}) { // Do nothing if click on the suggestion, as it's already a , the // browser will take care of it. This allow Ctrl-Clicking on results and not // having the main window being redirected as well if (context.selectionMethod === 'click') { return; } input.setVal(''); window.location.assign(suggestion.url); } handleShown(input) { const middleOfInput = input.offset().left + input.width() / 2; let middleOfWindow = $(document).width() / 2; if (isNaN(middleOfWindow)) { middleOfWindow = 900; } const alignClass = middleOfInput - middleOfWindow >= 0 ? 'algolia-autocomplete-right' : 'algolia-autocomplete-left'; const otherAlignClass = middleOfInput - middleOfWindow < 0 ? 'algolia-autocomplete-right' : 'algolia-autocomplete-left'; const autocompleteWrapper = $('.algolia-autocomplete'); if (!autocompleteWrapper.hasClass(alignClass)) { autocompleteWrapper.addClass(alignClass); } if (autocompleteWrapper.hasClass(otherAlignClass)) { autocompleteWrapper.removeClass(otherAlignClass); } } } export default DocSearch;