coveo-search-ui
Version:
Coveo JavaScript Search Framework
350 lines (312 loc) • 13.9 kB
text/typescript
import 'styling/_OmniboxResultList';
import * as _ from 'underscore';
import { IPopulateOmniboxEventArgs, OmniboxEvents } from '../../events/OmniboxEvents';
import { IBuildingQueryEventArgs, QueryEvents } from '../../events/QueryEvents';
import { InitializationEvents } from '../../EventsModules';
import { exportGlobally } from '../../GlobalExports';
import { Assert } from '../../misc/Assert';
import { Logger } from '../../misc/Logger';
import { IQueryResult } from '../../rest/QueryResult';
import { IQueryResults } from '../../rest/QueryResults';
import { l } from '../../strings/Strings';
import { $$ } from '../../utils/Dom';
import { Utils } from '../../utils/Utils';
import { analyticsActionCauseList, IAnalyticsNoMeta } from '../Analytics/AnalyticsActionListMeta';
import { logSearchBoxSubmitEvent } from '../Analytics/SharedAnalyticsCalls';
import { Component } from '../Base/Component';
import { IComponentBindings } from '../Base/ComponentBindings';
import { ComponentOptions } from '../Base/ComponentOptions';
import { IQueryExpression } from '../Base/IComponentOptions';
import { Initialization } from '../Base/Initialization';
import { ResultList } from '../ResultList/ResultList';
import { IResultListOptions } from '../ResultList/ResultListOptions';
import OmniboxModuleDefintion = require('../Omnibox/Omnibox');
export interface IOmniboxResultListOptions extends IResultListOptions {
omniboxZIndex?: number;
onSelect?: (result: IQueryResult, resultElement: HTMLElement, omniboxObject: IPopulateOmniboxEventArgs, event?: Event) => void;
headerTitle?: string;
queryOverride?: IQueryExpression;
}
/**
* The OmniboxResultList component behaves exactly like the {@link ResultList} component (which it extends), except that
* it renders itself inside the {@link Omnibox} component.
*
* This component can provide a kind of search-as-you-type functionality, allowing you to easily render complex Result
* Templates inside the Omnibox component.
*
* **Example:**
*
* ```html
* <div class="CoveoOmniboxResultList">
* <script class="result-template" type="text/x-underscore">
* <div>
* <a class='CoveoResultLink'></a>
* </div>
* </script>
* </div>
* ```
*/
export class OmniboxResultList extends ResultList implements IComponentBindings {
static ID = 'OmniboxResultList';
/**
* Specifies a list a css class that should be ignored when the end user click result in the omnibox
*
* Any element that is specified here should normally be able to handle the standard click event.
*
* Any element that does not match this css class and that is clicked will trigger a redirection by the OmniboxResultList.
*/
static elementsToIgnore = [
'coveo-field-table-toggle-caption',
'CoveoFollowItem',
'CoveoPrintableUri',
'CoveoQuickview',
'CoveoResultLink',
'CoveoResultRating',
'CoveoResultTagging',
'CoveoYouTubeThumbnail'
];
static doExport = () => {
exportGlobally({
OmniboxResultList: OmniboxResultList
});
};
/**
* The options for the component
* @componentOptions
*/
static options: IOmniboxResultListOptions = {
layout: ComponentOptions.buildStringOption({
defaultValue: 'list',
postProcessing: optionSetByUser => {
if (optionSetByUser != 'list') {
const logger = new Logger(OmniboxResultList);
logger.warn(`Cannot apply layout ${optionSetByUser} on the OmniboxResultListComponent`);
logger.warn(`OmniboxResultList does not support any layout other than "list"`);
}
return 'list';
}
}),
/**
* Specifies the z-index at which to render the ResultList inside the Omnibox.
*
* Default value is `51`. Minimum value is `16` ({@link Facet} components are at `50` by default)
*/
omniboxZIndex: ComponentOptions.buildNumberOption({ defaultValue: 51, min: 16 }),
/**
* Specifies the title to use for this section.
*
* Default value is the localized string for `Suggested Results`.
*/
headerTitle: ComponentOptions.buildStringOption(),
/**
* Specifies the override to use on the query sent to the OmniboxResultList component.
*
* Default value is `undefined`, which means no default override is specified.
*/
queryOverride: ComponentOptions.buildQueryExpressionOption(),
/**
* Specifies the function to execute when the user selects a result suggestion.
*
* The default function opens the corresponding result URI in the browser.
*
* It is only possible to specify a value for this option in the {@link init} call of your search interface. You
* cannot set it directly as an HTML attribute.
*
* **Example:**
*
* ```javascript
* // You can call the init script using "pure" JavaScript:
* Coveo.init(document.querySelector('#search'), {
* OmniboxResultList : {
* //Close the omnibox, change the selected HTMLElement background color and alert the result title.
* onSelect : function(result, resultElement, omniBoxObject) {
* omniBoxObject.close();
* resultElement.css('background-color', 'red');
* alert(result.title);
* }
* }
* })
*
* // Or you can call the init script using the jQuery extension:
* $("#search").coveo("init", {
* OmniboxResultList : {
* //Close the Omnibox, change the selected HTMLElement background color and alert the result title.
* onSelect : function(result, resultElement, omniBoxObject) {
* omniBoxObject.close();
* resultElement.css('background-color', 'red');
* alert(result.title);
* }
* }
* })
* ```
*/
onSelect: ComponentOptions.buildCustomOption<
(result: IQueryResult, resultElement: HTMLElement, omniboxObject: IPopulateOmniboxEventArgs) => void
>(() => {
return null;
})
};
private lastOmniboxRequest: { omniboxObject: IPopulateOmniboxEventArgs; resolve: (...args: any[]) => void };
/**
* Creates a new OmniboxResultList component.
* @param element The HTMLElement on which to instantiate the component.
* @param options The options for the OmniboxResultList component.
* @param bindings The bindings that the component requires to function normally. If not set, these will be
* automatically resolved (with a slower execution time).
*/
constructor(public element: HTMLElement, public options?: IOmniboxResultListOptions, public bindings?: IComponentBindings) {
super(element, options, bindings, OmniboxResultList.ID);
this.options = ComponentOptions.initComponentOptions(element, OmniboxResultList, options);
this.setupOptions();
this.bind.onRootElement(OmniboxEvents.populateOmnibox, (args: IPopulateOmniboxEventArgs) => this.handlePopulateOmnibox(args));
this.bind.onRootElement(QueryEvents.buildingQuery, (args: IBuildingQueryEventArgs) => this.handleBuildingQuery(args));
this.bind.onRootElement(InitializationEvents.afterComponentsInitialization, () => {
this.handleAfterComponentInit();
});
}
/**
* Builds and returns an array of `HTMLElement` from the {@link IQueryResults} set received as an argument.
* @param results The IQueryResults set to build an array of `HTMLElement` from.
*/
public buildResults(results: IQueryResults): Promise<HTMLElement[]> {
const builtResults: HTMLElement[] = [];
const builtPromises = _.map(results.results, (result: IQueryResult) => {
return this.buildResult(result).then((resultElement: HTMLElement) => {
$$(resultElement).addClass('coveo-omnibox-selectable');
resultElement['no-text-suggestion'] = true;
$$(resultElement).on(['keyboardSelect', 'click'], (e: Event) => this.handleOmniboxElementSelection(e, resultElement, result));
return this.autoCreateComponentsInsideResult(resultElement, result).initResult.then(() => {
builtResults.push(resultElement);
return resultElement;
});
});
});
return Promise.all(builtPromises).then(() => {
return builtResults;
});
}
/**
* Creates a result container and appends each element from the received `HTMLElement` array to it. For each element
* it appends to the result container, this method triggers a `newResultDisplayed` event. Once all elements have been
* appended to the result container, the method triggers a `newResultsDisplayed` event.
* @param resultElements The array of `HTMLElement` to render.
* @param append
*/
public renderResults(resultElements: HTMLElement[], append = false) {
$$(this.options.resultsContainer).empty();
if (!this.lastOmniboxRequest) {
return Promise.resolve(null);
}
if (resultElements.length) {
this.appendHeaderIfTitleIsSpecified();
this.appendResults(resultElements);
}
this.resolveLastOmniboxRequest();
return Promise.resolve(null);
}
private handleAfterComponentInit() {
const omniboxElement: HTMLElement = $$(this.root).find(`.${Component.computeCssClassNameForType('Omnibox')}`);
if (omniboxElement) {
const omnibox = <OmniboxModuleDefintion.Omnibox>Component.get(omniboxElement);
const magicBox = omnibox.magicBox;
magicBox.onsubmit = () => {
logSearchBoxSubmitEvent(this.usageAnalytics);
this.queryController.executeQuery();
};
}
}
private appendHeaderIfTitleIsSpecified() {
if (this.options.headerTitle) {
this.options.resultsContainer.appendChild(
$$(
'div',
{ className: 'coveo-omnibox-result-list-header' },
$$('span', { className: 'coveo-icon-omnibox-result-list' }).el,
$$('span', { className: 'coveo-caption' }, l(this.options.headerTitle)).el
).el
);
}
}
private appendResults(resultElements: HTMLElement[]) {
_.each(resultElements, (resultElement: HTMLElement) => {
this.options.resultsContainer.appendChild(resultElement);
this.triggerNewResultDisplayed(Component.getResult(resultElement), resultElement);
});
this.triggerNewResultsDisplayed();
}
private resolveLastOmniboxRequest() {
if ($$(this.options.resultsContainer).findAll('.coveo-omnibox-selectable').length == 0) {
this.lastOmniboxRequest.resolve({ element: null, zIndex: this.options.omniboxZIndex });
} else {
this.lastOmniboxRequest.resolve({ element: this.options.resultsContainer, zIndex: this.options.omniboxZIndex });
}
}
protected handleChangeLayout() {
// Since OmniboxResultList does not allow multiple layout,
// we simply ignore the event change since the component does not have anything meaningful to do.
return;
}
private setupOptions() {
this.logger.info('Disabling infinite scroll for OmniboxResultList', this);
this.options.enableInfiniteScroll = false;
this.options.onSelect = this.options.onSelect || this.onRowSelection;
}
private handlePopulateOmnibox(args: IPopulateOmniboxEventArgs) {
const promise = new Promise((resolve, reject) => {
this.queryController.executeQuery({
searchAsYouType: true,
shouldRedirectStandaloneSearchbox: false,
beforeExecuteQuery: () => this.usageAnalytics.logSearchAsYouType<IAnalyticsNoMeta>(analyticsActionCauseList.searchboxSubmit, {})
});
this.lastOmniboxRequest = { omniboxObject: args, resolve: resolve };
});
args.rows.push({
deferred: promise
});
}
protected handleBuildingQuery(args: IBuildingQueryEventArgs) {
Assert.exists(args);
if (Utils.isNonEmptyString(this.options.queryOverride)) {
args.queryBuilder.constantExpression.add(this.options.queryOverride);
}
}
private handleOmniboxElementSelection(e: Event, resultElement: HTMLElement, result: IQueryResult) {
if (e && e.target && this.otherComponentShouldHandleSelection(e, resultElement)) {
return;
}
if (this.lastOmniboxRequest) {
this.options.onSelect.call(this, result, resultElement, this.lastOmniboxRequest.omniboxObject, e);
}
}
private otherComponentShouldHandleSelection(e: Event, resultElement: HTMLElement) {
// Other components can "trap" the click event, and instead trigger the "standard" component behaviour.
// So, for example, if someones clicks the ResultLink directly, we want the result link code to execute to redirect to the result, and not the OmniboxResultList selection code.
// Same for Quickview, YouTubeThumbnail, etc.
let current = e.target as HTMLElement;
let otherComponentWillHandleClick = false;
while (current && current != resultElement) {
otherComponentWillHandleClick =
_.find(OmniboxResultList.elementsToIgnore, elementToIgnore => $$(current).hasClass(elementToIgnore)) != null;
if (otherComponentWillHandleClick) {
break;
}
current = current.parentElement;
}
return otherComponentWillHandleClick;
}
private onRowSelection(result: IQueryResult, resultElement: HTMLElement, omniboxObject: IPopulateOmniboxEventArgs, e: Event) {
this.usageAnalytics.logClickEvent(
analyticsActionCauseList.documentOpen,
{ author: Utils.getFieldValue(result, 'author') },
result,
this.root
);
window.location.href = result.clickUri;
}
protected initResultContainerAddToDom() {
//This function is overwritten and don't do anything because we don't want to append the result container
//to the DOM. If the resultContainer was to be appended to the DOM, this could lead to result appearing
//outside of the magicBox so the rest of the page would be push down
}
}
Initialization.registerAutoCreateComponent(OmniboxResultList);