/* global $, AC, libreviews */
'use strict';
const AbstractLookupAdapter = require('./abstract-lookup-adapter');
// Even when selecting via search, we still want to check whether there's a
// native entry for this URL
const NativeLookupAdapter = require('./native-lookup-adapter');
const nativeLookupAdapter = new NativeLookupAdapter();
/**
* Adapter that handles ordinary URL lookup (as specified in
* AbstractLookupAdapter) and autocomplete searches. Autocomplete searches
* rely on the [remote-ac package](https://www.npmjs.com/package/remote-ac)
* written by Danqing Liu.
*
* The autocomplete class (`AC` global) must exist before this code is run.
* We communicate with the widget using callbacks, prefixed with `_`,
* which are bound to it.
*
* @abstract
* @extends AbstractLookupAdapter
*/
class AbstractAutocompleteAdapter extends AbstractLookupAdapter {
/**
* @param {Function} updateCallback - Callback to run after a row has been
* selected.
* @param {String} searchBoxSelector - jQuery selector for input we're adding
* the autocomplete widget to.
*/
constructor(updateCallback, searchBoxSelector) {
super(updateCallback);
if (this.constructor.name === AbstractAutocompleteAdapter.name)
throw new TypeError('AbstractAutocompleteAdapter is an abstract class, please instantiate a derived class.');
this.searchBoxSelector = searchBoxSelector;
/**
* Delay in milliseconds before performing a search.
*
* @type {Number}
*/
this.acDelay = 300;
/**
* CSS prefix for the autocomplete widget.
*
* @type {String}
*/
this.acCSSPrefix = 'ac-adapter-';
/**
* Key (into the `row` objects retrieved via the request handler)
* that determines which value is used as the main text in the autocomplete
* widget.
*
* `'label'` corresponds to what the main application expects, but if you
* want to show something different than what gets passed to the
* application, you may want to change it.
*
* @type {String}
*/
this.acPrimaryTextKey = 'label';
/**
* Default row key for the optional secondary, smaller text shown in the
* autocomplete widget below each result.
*
* @type {String}
*/
this.acSecondaryTextKey = 'description';
/**
* Callback for fetching row data.
*
* @function
* @abstract
* @this AbstractAutocompleteAdapter#ac
* @param {String} query - The characters entered by the user
*/
this._requestHandler = this._requestHandler || null;
/**
* Callback for rendering a row within the autocomplete widget, overriding
* default rendering.
*
* @function
* @abstract
* @this AbstractAutocompleteAdapter#ac
* @param {Object} row - The row object to render
*
*/
this._renderRowHandler = this._renderRowHandler || null;
}
/**
* Initialize the autocomplete widget. You can add additional callbacks /
* custom properties in the inherited class; just remember to call
* `super.setupAutocomplete()` first.
*/
setupAutocomplete() {
let ac = new AC($(this.searchBoxSelector)[0]);
ac.primaryTextKey = this.acPrimaryTextKey;
ac.secondaryTextKey = this.acSecondaryTextKey;
ac.delay = this.acDelay;
ac.cssPrefix = this.acCSSPrefix;
ac.adapter = this;
// Register standard callbacks
if (this._requestHandler)
ac.requestFn = this._requestHandler.bind(ac);
if (this._selectRowHandler)
ac.triggerFn = this._selectRowHandler.bind(ac);
if (this._renderRowHandler)
ac.rowFn = this._renderRowHandler.bind(ac);
// Custom function for showing "No results" text
ac.renderNoResults = this._renderNoResultsHandler.bind(ac);
/**
* After {@link AbstractAutocompleteAdapter#setupAutocomplete} is run,
* holds a reference to the autocomplete widget used by this instance.
*
* @type {AC}
* @member
*/
this.ac = ac;
}
/**
* Remove the autocomplete widget including all its event listeners.
*/
removeAutocomplete() {
if (this.ac) {
this.ac.deactivate();
this.ac = undefined;
}
}
/**
* Run the autocomplete widget on the current input.
*/
runAutocomplete() {
if (this.ac) {
this.ac.inputEl.focus();
this.ac.inputHandler();
}
}
/**
* Show activity indicator in the input widget. Must be called in handler
* code via this.adapter.
*/
enableSpinner() {
$(`${this.searchBoxSelector} + span.input-spinner`).removeClass('hidden');
}
/**
* Hide activity indicator in the input widget. Must be called in handler
* code via this.adapter.
*/
disableSpinner() {
$(`${this.searchBoxSelector} + span.input-spinner`).addClass('hidden');
}
/**
* Pass along row data we can handle to the main application. Will also
* query lib.reviews itself (through the native adapter) for the URL, so
* we can give preferential treatment to an existing native record for the
* review subject.
*
* @this AbstractAutocompleteAdapter#ac
* @param {Object} row
* row data object. All properties except "url" are only used for display
* purposes, since the server performs its own lookup on the URL.
* @param {String} row.url
* the URL for this review subject
* @param {String} row.label
* the main name shown for this review subject
* @param {String} [row.subtitle]
* shown as secondary title for the subject
* @param {String} [row.description]
* shown as short description below label and subtitle
* @param {Event} event
* the click or keyboard event which triggered this row selection.
*/
_selectRowHandler(row, event) {
event.preventDefault();
if (row.url && row.label) {
const data = {
label: row.label,
url: row.url
};
if (row.subtitle)
data.subtitle = row.subtitle;
if (row.description)
data.description = row.description;
// Let the application perform appropriate updates based on this data
this.adapter.updateCallback(data);
// Check if we have local record and if so, replace lookup results
nativeLookupAdapter
.lookup(row.url)
.then(result => {
if (result && result.data) {
result.data.url = row.url;
this.adapter.updateCallback(result.data);
}
})
.catch(() => {
// Do nothing
});
}
}
/**
* Render "No search results" text row at the bottom with default styles.
*
* @this AbstractAutocompleteAdapter#ac
*/
_renderNoResultsHandler() {
const $wrapper = $(this.rowWrapperEl);
const $noResults = $('<div class="ac-adapter-no-results">' + libreviews.msg('no search results') + '</div>');
$wrapper
.append($noResults)
.show();
}
}
module.exports = AbstractAutocompleteAdapter;