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"; /** * Adds an autocomplete dropdown to an input field * @function DocSearch * @param {Object} options.searchDocs Search Documents * @param {Object} options.searchIndex Lune searchIndexes * @param {string} options.inputSelector CSS selector that targets the input * value. * @param {Object} [options.autocompleteOptions] Options to pass to the underlying autocomplete instance * @return {Object} */ class DocSearch { constructor({ searchDocs, searchIndex, inputSelector, debug = false, 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); 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;