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;