coveo-search-ui
Version:
Coveo JavaScript Search Framework
745 lines (638 loc) • 24.8 kB
text/typescript
import { buildHistoryStore, buildNullHistoryStore } from '../utils/HistoryStore';
import { pick, extend, omit, forEach } from 'underscore';
import {
IBuildingCallOptionsEventArgs,
IBuildingQueryEventArgs,
IDoneBuildingQueryEventArgs,
IDuringQueryEventArgs,
IFetchMoreSuccessEventArgs,
INewQueryEventArgs,
INoResultsEventArgs,
IPreprocessResultsEventArgs,
IQueryErrorEventArgs,
IQuerySuccessEventArgs,
QueryEvents
} from '../events/QueryEvents';
import { ModalBox } from '../ExternalModulesShim';
import { Assert } from '../misc/Assert';
import { Defer } from '../misc/Defer';
import { IQuery } from '../rest/Query';
import { IQueryResults } from '../rest/QueryResults';
import { SearchEndpoint } from '../rest/SearchEndpoint';
import { IEndpointCallOptions, ISearchEndpoint } from '../rest/SearchEndpointInterface';
import { SearchEndpointWithDefaultCallOptions } from '../rest/SearchEndpointWithDefaultCallOptions';
import { BaseComponent } from '../ui/Base/BaseComponent';
import { QueryBuilder } from '../ui/Base/QueryBuilder';
import { RootComponent } from '../ui/Base/RootComponent';
import { ISearchInterfaceOptions, SearchInterface } from '../ui/SearchInterface/SearchInterface';
import { $$, Dom } from '../utils/Dom';
import { LocalStorageUtils } from '../utils/LocalStorageUtils';
import { QueryUtils } from '../utils/QueryUtils';
import { UrlUtils } from '../utils/UrlUtils';
import { Utils } from '../utils/Utils';
import { IAnalyticsClient } from '../ui/Analytics/AnalyticsClient';
import { ExecutionPlan } from '../rest/Plan';
import { AnalyticsInformation } from '../ui/Analytics/AnalyticsInformation';
import { QueryboxQueryParameters } from '../ui/Querybox/QueryboxQueryParameters';
/**
* Possible options when performing a query with the query controller
*/
export interface IQueryOptions {
/**
* If the analytics component is enabled in the interface, it will look for any query executed by the query controller for which no analytics event was associated.<br/>
* By setting this property to true, this will cancel this check when the query is performed
*/
ignoreWarningSearchEvent?: boolean;
/**
* Whether the query to execute is a search-as-you-type. This information will be passed down in the query events for component and external code to determine their behavior
*/
searchAsYouType?: boolean;
/**
* Specify a function that you wish to execute just before the query is executed
*/
beforeExecuteQuery?: () => void;
/**
* Cancel the query
*/
cancel?: boolean;
/**
* The component from which the query originated. For example the pager will set the property to tweak it's behaviour
*/
origin?: any;
/**
* Whether or not to log the query in the user actions history when using the page view script: https://github.com/coveo/coveo.analytics.js.
* Only the 'q' part of the query will be logged.
* This option is useful, because it prevents the query to be logged twice when a {@link Recommendation} component is present.
* It also makes sure that only relevant queries are logged. For exemple, the 'empty' interface load query isn't logged.
*/
logInActionsHistory?: boolean;
isFirstQuery?: boolean;
keepLastSearchUid?: boolean;
closeModalBox?: boolean;
shouldRedirectStandaloneSearchbox?: boolean;
}
interface ILastQueryLocalStorage {
hash: string;
uid: string;
expire: number;
}
class DefaultQueryOptions implements IQueryOptions {
searchAsYouType = false;
beforeExecuteQuery: () => void;
cancel = false;
logInActionsHistory = false;
shouldRedirectStandaloneSearchbox = true;
}
/**
* This class is automatically instantiated and bound to the root of your search interface when you initialize the framework.<br/>
* It is essentially a singleton that wraps the access to the {@link SearchEndpoint} endpoint to execute query, and is in charge of triggering the different query events.<br/>
* This is what every component of the framework uses internally to execute query or access the endpoint.<br/>
* When calling <code>Coveo.executeQuery</code> this class is used.
*/
export class QueryController extends RootComponent {
static ID = 'QueryController';
public historyStore: CoveoAnalytics.HistoryStore;
public firstQuery: boolean;
public modalBox = ModalBox;
public closeModalBox = true;
private currentPendingQuery: Promise<IQueryResults>;
private lastQueryBuilder: QueryBuilder;
private lastQueryHash: string;
private lastQuery: IQuery;
private lastSearchUid: string;
private lastQueryResults: IQueryResults;
private currentError: any;
private createdOneQueryBuilder: boolean;
private showingExecutingQueryAnimation = false;
private overrideEndpoint: SearchEndpoint;
private localStorage = new LocalStorageUtils<ILastQueryLocalStorage>('lastQueryHash');
/**
* Create a new query controller
* @param element
* @param options
* @param usageAnalytics **Deprecated.** Since the [October 2019 Release (v2.7219)](https://docs.coveo.com/en/3084/), the class retrieves and uses the {@link AnalyticsClient} from its `searchInterface` constructor parameter.
* @param searchInterface
*/
constructor(
element: HTMLElement,
public options: ISearchInterfaceOptions,
usageAnalytics: IAnalyticsClient,
public searchInterface: SearchInterface
) {
super(element, QueryController.ID);
Assert.exists(element);
Assert.exists(options);
this.firstQuery = true;
this.initializeActionsHistory();
}
public get usageAnalytics(): IAnalyticsClient {
return this.searchInterface.usageAnalytics;
}
/**
* Set the {@link SearchEndpoint} that the query controller should use to execute query
* @param endpoint
*/
public setEndpoint(endpoint: SearchEndpoint) {
this.overrideEndpoint = endpoint;
this.logger.debug('Endpoint set', endpoint);
}
/**
* Get the {@link SearchEndpoint} that is currently used by the query controller to execute query
* @returns {SearchEndpoint}
*/
public getEndpoint(): ISearchEndpoint {
let endpoint = this.overrideEndpoint || this.options.endpoint;
// We must wrap the endpoint in a decorator that'll add the call options
// we obtain by firing the proper event. Those are used for authentication
// providers, and I guess other stuff later on.
return new SearchEndpointWithDefaultCallOptions(endpoint, this.getCallOptions());
}
/**
* Return the last query that was performed by the query controller
* @returns {IQuery|Query}
*/
public getLastQuery() {
return this.lastQuery || new QueryBuilder().build();
}
/**
* Return the last query results set.
* @returns {IQueryResults}
*/
public getLastResults() {
return this.lastQueryResults;
}
/**
* Returns the plan of execution of a search request, without executing it.
* @returns {ExecutionPlan}
*/
public async fetchQueryExecutionPlan(): Promise<ExecutionPlan> {
const query = this.createQueryBuilder(new DefaultQueryOptions()).build();
this.logger.debug('Fetching query execution plan');
try {
return await this.getEndpoint().plan(query);
} catch (error) {
return null;
}
}
/**
* Execute a query and return a Promise of IQueryResults.<br/>
* This will execute the normal query flow, triggering all the necessary query events (newQuery <br/>
* All components present in the interface will act accordingly (modify the query and render results if needed).
* @param options
* @returns {Promise<IQueryResults>}
*/
public executeQuery(options?: IQueryOptions): Promise<IQueryResults> {
options = <IQueryOptions>extend(new DefaultQueryOptions(), options);
this.closeModalBoxIfNeeded(options ? options.closeModalBox : undefined);
this.logger.debug('Executing new query');
this.cancelAnyCurrentPendingQuery();
if (options.beforeExecuteQuery != null) {
options.beforeExecuteQuery();
}
if (!options.ignoreWarningSearchEvent) {
this.usageAnalytics.warnAboutSearchEvent();
}
this.showExecutingQueryAnimation();
let dataToSendOnNewQuery: INewQueryEventArgs = {
searchAsYouType: options.searchAsYouType,
cancel: options.cancel,
origin: options.origin,
shouldRedirectStandaloneSearchbox: options.shouldRedirectStandaloneSearchbox
};
this.newQueryEvent(dataToSendOnNewQuery);
if (dataToSendOnNewQuery.cancel) {
this.cancelQuery();
return;
}
let queryBuilder = this.createQueryBuilder(options);
// The query was canceled
if (!queryBuilder) {
return;
}
let query = queryBuilder.build();
if (options.logInActionsHistory) {
this.logQueryInActionsHistory(query);
}
let endpointToUse = this.getEndpoint();
let promise = (this.currentPendingQuery = endpointToUse.search(query, { analyticsInformation: this.getAnalyticsInformation() }));
promise
.then(queryResults => {
Assert.exists(queryResults);
if (this.firstQuery) {
this.firstQuery = false;
}
// If our promise is no longer the current one, then the query
// has been cancel. We should do nothing here.
if (promise !== this.currentPendingQuery) {
return;
}
this.logger.debug('Query results received', query, queryResults);
if (this.keepLastSearchUid(query, queryResults, options)) {
queryResults.searchUid = this.getLastSearchUid();
queryResults._reusedSearchUid = true;
QueryUtils.setPropertyOnResults(queryResults, 'queryUid', this.getLastSearchUid());
} else {
this.lastQueryHash = this.queryHash(query, queryResults);
this.lastSearchUid = queryResults.searchUid;
}
this.lastQuery = query;
this.lastQueryResults = queryResults;
this.currentError = null;
let dataToSendOnPreprocessResult: IPreprocessResultsEventArgs = {
queryBuilder: queryBuilder,
query: query,
results: queryResults,
searchAsYouType: options.searchAsYouType
};
this.preprocessResultsEvent(dataToSendOnPreprocessResult);
let dataToSendOnNoResult: INoResultsEventArgs = {
queryBuilder: queryBuilder,
query: query,
results: queryResults,
searchAsYouType: options.searchAsYouType,
retryTheQuery: false
};
if (queryResults.results.length == 0) {
this.noResultEvent(dataToSendOnNoResult);
}
if (dataToSendOnNoResult.retryTheQuery) {
// When retrying the query, we must forward the results to the deferred we
// initially returned, in case someone is listening on it.
return this.executeQuery();
} else {
this.lastQueryBuilder = queryBuilder;
this.currentPendingQuery = undefined;
let dataToSendOnSuccess: IQuerySuccessEventArgs = {
queryBuilder: queryBuilder,
query: query,
results: queryResults,
searchAsYouType: options.searchAsYouType
};
this.querySuccessEvent(dataToSendOnSuccess);
Defer.defer(() => {
this.deferredQuerySuccessEvent(dataToSendOnSuccess);
this.hideExecutingQueryAnimation();
});
return queryResults;
}
})
.catch((error?: any) => {
// If our deferred is no longer the current one, then the query
// has been cancel. We should do nothing here.
if (promise !== this.currentPendingQuery) {
return;
}
this.logger.error('Query triggered an error', query, error);
this.currentPendingQuery = undefined;
let dataToSendOnError: IQueryErrorEventArgs = {
queryBuilder: queryBuilder,
endpoint: endpointToUse,
query: query,
error: error,
searchAsYouType: options.searchAsYouType
};
this.lastQuery = query;
this.lastQueryResults = null;
this.currentError = error;
this.queryError(dataToSendOnError);
this.hideExecutingQueryAnimation();
});
let dataToSendDuringQuery: IDuringQueryEventArgs = {
queryBuilder: queryBuilder,
query: query,
searchAsYouType: options.searchAsYouType,
promise: promise
};
this.duringQueryEvent(dataToSendDuringQuery);
return this.currentPendingQuery;
}
/**
* Using the same parameters as the last successful query, fetch another batch of results. Particularly useful for infinite scrolling, for example.
* @param count
* @returns {any}
*/
public fetchMore(count: number): Promise<IQueryResults> {
if (this.currentPendingQuery != undefined) {
return undefined;
}
// Send all pending events (think : search as you type)
// This allows us to get the real search id for the results when the query returns
this.usageAnalytics.sendAllPendingEvents();
let queryBuilder = new QueryBuilder();
this.continueLastQueryBuilder(queryBuilder, count);
let query = queryBuilder.build();
let endpointToUse = this.getEndpoint();
let promise: any = (this.currentPendingQuery = endpointToUse.search(query));
let dataToSendDuringQuery: IDuringQueryEventArgs = {
queryBuilder: queryBuilder,
query: query,
searchAsYouType: false,
promise: promise
};
$$(this.element).trigger(QueryEvents.duringFetchMoreQuery, dataToSendDuringQuery);
this.lastQueryBuilder = queryBuilder;
this.lastQuery = query;
promise.then((results?: IQueryResults) => {
// We re-use the search id from the initial search here, even though the
// server provided us with a new one. 'Fetch mores' are considered to be
// the same query from an analytics point of view.
this.currentPendingQuery = undefined;
if (this.lastQueryResults == null) {
this.lastQueryResults = results;
} else {
forEach(results.results, result => {
this.lastQueryResults.results.push(result);
});
}
let dataToSendOnPreprocessResult: IPreprocessResultsEventArgs = {
queryBuilder: queryBuilder,
query: query,
results: results,
searchAsYouType: false
};
this.preprocessResultsEvent(dataToSendOnPreprocessResult);
QueryUtils.setIndexAndUidOnQueryResults(query, results, this.getLastSearchUid(), results.pipeline, results.splitTestRun);
let dataToSendOnFetchMoreSuccess: IFetchMoreSuccessEventArgs = {
query: query,
results: results,
queryBuilder: queryBuilder,
searchAsYouType: false
};
this.fetchMoreSuccessEvent(dataToSendOnFetchMoreSuccess);
});
return this.currentPendingQuery;
}
/**
* Cancel any pending query
*/
public cancelQuery(): void {
this.cancelAnyCurrentPendingQuery();
this.hideExecutingQueryAnimation();
}
public deferExecuteQuery(options?: IQueryOptions) {
this.showExecutingQueryAnimation();
Defer.defer(() => this.executeQuery(options));
}
public ensureCreatedQueryBuilder() {
if (!this.createdOneQueryBuilder) {
this.createQueryBuilder(new DefaultQueryOptions());
}
}
public createQueryBuilder(options: IQueryOptions): QueryBuilder {
Assert.exists(options);
QueryboxQueryParameters.allowDuplicateQuery();
this.createdOneQueryBuilder = true;
let queryBuilder = new QueryBuilder();
// Default values, components will probably override them if they exists
queryBuilder.locale = <string>String['locale'];
queryBuilder.firstResult = queryBuilder.firstResult || 0;
// Allow outside code to customize the query builder. We provide two events,
// to allow someone to have a peep at the query builder after the first phase
// and add some stuff depending on what was put in there. The facets are using
// this mechanism to generate query overrides.
let dataToSendDuringBuildingQuery: IBuildingQueryEventArgs = {
queryBuilder: queryBuilder,
searchAsYouType: options.searchAsYouType,
cancel: options.cancel
};
this.buildingQueryEvent(dataToSendDuringBuildingQuery);
let dataToSendDuringDoneBuildingQuery: IDoneBuildingQueryEventArgs = {
queryBuilder: queryBuilder,
searchAsYouType: options.searchAsYouType,
cancel: options.cancel
};
this.doneBuildingQueryEvent(dataToSendDuringDoneBuildingQuery);
if (dataToSendDuringBuildingQuery.cancel || dataToSendDuringDoneBuildingQuery.cancel) {
this.cancelQuery();
return;
}
let pipeline = this.getPipelineInUrl();
if (pipeline) {
queryBuilder.pipeline = pipeline;
}
return queryBuilder;
}
public isStandaloneSearchbox(): boolean {
return Utils.isNonEmptyString(this.options.searchPageUri);
}
public saveLastQuery() {
this.localStorage.save({
expire: new Date().getTime() + 1000 * 60 * 30, // Psoucy rolled dice and said 30 mins was the magic number
hash: this.lastQueryHash,
uid: this.lastSearchUid
});
}
// This field is exposed for components rendered in the results or on-demand which
// need access to the entire query. For example, the QuickviewDocument need to pass
// the entire query to the Search API. For other components, QueryStateModel or
// listening to events like 'doneBuildingQuery' is the way to go.
public getLastQueryHash(): string {
if (this.lastQueryHash) {
return this.lastQueryHash;
}
this.loadLastQueryHash();
return this.lastQueryHash || this.queryHash(new QueryBuilder().build());
}
public resetHistory() {
this.historyStore.clear();
}
public enableHistory() {
this.historyStore = buildHistoryStore();
}
public disableHistory() {
this.historyStore = buildNullHistoryStore();
}
private initializeActionsHistory() {
this.enableHistory();
if (this.usageAnalytics.isActivated()) {
return;
}
this.historyStore.clear();
this.disableHistory();
}
private closeModalBoxIfNeeded(needed?: boolean) {
if (needed != undefined ? needed : this.closeModalBox) {
this.modalBox.close(true);
}
}
private getLastSearchUid(): string {
if (this.lastSearchUid != null) {
return this.lastSearchUid;
}
this.loadLastQueryHash();
return this.lastSearchUid;
}
private loadLastQueryHash() {
let lastQuery = this.localStorage.load();
if (lastQuery != null && new Date().getTime() <= lastQuery.expire) {
this.lastQueryHash = lastQuery.hash;
this.lastSearchUid = lastQuery.uid;
this.localStorage.remove();
}
}
private continueLastQueryBuilder(queryBuilder: QueryBuilder, count: number) {
extend(queryBuilder, this.lastQueryBuilder);
queryBuilder.firstResult = queryBuilder.firstResult + queryBuilder.numberOfResults;
queryBuilder.numberOfResults = count;
}
private getPipelineInUrl() {
return UrlUtils.getUrlParameter('pipeline');
}
private cancelAnyCurrentPendingQuery() {
if (Utils.exists(this.currentPendingQuery)) {
this.logger.debug('Cancelling current pending query');
Promise.reject('Cancelling current pending query');
this.currentPendingQuery = undefined;
return true;
}
return false;
}
private showExecutingQueryAnimation() {
if (!this.showingExecutingQueryAnimation) {
$$(this.element).addClass('coveo-executing-query');
this.showingExecutingQueryAnimation = true;
}
}
private hideExecutingQueryAnimation() {
if (this.showingExecutingQueryAnimation) {
$$(this.element).removeClass('coveo-executing-query');
this.showingExecutingQueryAnimation = false;
}
}
private keepLastSearchUid(query: IQuery, queryResults: IQueryResults, options: IQueryOptions) {
if (options.keepLastSearchUid === true) {
return true;
}
const enableHistory = !!(this.searchInterface && this.searchInterface.options && this.searchInterface.options.enableHistory);
return enableHistory && this.compareWithLastQueryHash(this.queryHash(query, queryResults));
}
private compareWithLastQueryHash(queryHash: string) {
const lastParams = JSON.parse(this.getLastQueryHash());
const newParams = JSON.parse(queryHash);
return Utils.objectEqual(lastParams, newParams);
}
private queryHash(query: IQuery, queryResults?: IQueryResults): string {
const queryKeys: (keyof IQuery)[] = [
'q',
'aq',
'cq',
'dq',
'lq',
'searchHub',
'tab',
'pipeline',
'sortCriteria',
'recommendation',
'commerce',
'facets'
];
const queryParams = pick(query, ...queryKeys);
if (queryResults) {
queryParams.pipeline = queryResults.pipeline;
}
return JSON.stringify(queryParams);
}
private getCallOptions(): IEndpointCallOptions {
let args: IBuildingCallOptionsEventArgs = {
options: {
authentication: []
}
};
$$(this.element).trigger(QueryEvents.buildingCallOptions, args);
return args.options;
}
private newQueryEvent(args) {
$$(this.element).trigger(QueryEvents.newQuery, args);
}
private buildingQueryEvent(args) {
$$(this.element).trigger(QueryEvents.buildingQuery, args);
}
private doneBuildingQueryEvent(args) {
$$(this.element).trigger(QueryEvents.doneBuildingQuery, args);
}
private duringQueryEvent(args) {
$$(this.element).trigger(QueryEvents.duringQuery, args);
}
private querySuccessEvent(args) {
$$(this.element).trigger(QueryEvents.querySuccess, args);
}
private fetchMoreSuccessEvent(args) {
$$(this.element).trigger(QueryEvents.fetchMoreSuccess, args);
}
private deferredQuerySuccessEvent(args) {
$$(this.element).trigger(QueryEvents.deferredQuerySuccess, args);
}
private queryError(args) {
$$(this.element).trigger(QueryEvents.queryError, args);
}
private preprocessResultsEvent(args) {
$$(this.element).trigger(QueryEvents.preprocessResults, args);
}
private noResultEvent(args) {
$$(this.element).trigger(QueryEvents.noResults, args);
}
public debugInfo() {
let info: any = {
query: this.lastQuery
};
if (this.lastQueryResults != null) {
info.queryDuration = () => this.buildQueryDurationSection(this.lastQueryResults);
info.results = () => omit(this.lastQueryResults, 'results');
}
if (this.currentError != null) {
info.error = () => this.currentError;
}
return info;
}
private buildQueryDurationSection(queryResults: IQueryResults) {
let dom = Dom.createElement('div', { className: 'coveo-debug-queryDuration' });
let graph = Dom.createElement('div', { className: 'coveo-debug-durations' });
let debugRef = BaseComponent.getComponentRef('Debug');
dom.appendChild(graph);
forEach(debugRef.durationKeys, (key: string) => {
let duration = queryResults[key];
if (duration != null) {
graph.appendChild(
Dom.createElement('div', {
className: 'coveo-debug-duration',
style: `width:${duration}px`,
'data-id': key
})
);
let legend = Dom.createElement('div', { className: 'coveo-debug-duration-legend', 'data-id': key });
dom.appendChild(legend);
let keyDom = Dom.createElement('span', { className: 'coveo-debug-duration-label' });
keyDom.appendChild(document.createTextNode(key));
legend.appendChild(keyDom);
let durationDom = Dom.createElement('span', { className: 'coveo-debug-duration-value' });
durationDom.appendChild(document.createTextNode(duration));
legend.appendChild(durationDom);
}
});
return dom;
}
private logQueryInActionsHistory(query: IQuery) {
let queryElement: CoveoAnalytics.HistoryQueryElement = {
name: 'Query',
value: query.q,
time: JSON.stringify(new Date())
};
this.historyStore.addElement(queryElement);
}
private getAnalyticsInformation(): AnalyticsInformation {
const analyticsInfo = new AnalyticsInformation();
analyticsInfo.pendingSearchEvent = this.usageAnalytics.getPendingSearchEvent();
// add fallback for custom code that incorrectly implement the interface.
if (this.usageAnalytics.getOriginContext) {
analyticsInfo.originContext = this.usageAnalytics.getOriginContext();
} else {
analyticsInfo.originContext = 'Search';
}
if (this.usageAnalytics.getUserDisplayName) {
analyticsInfo.userDisplayName = this.usageAnalytics.getUserDisplayName();
} else {
analyticsInfo.userDisplayName = undefined;
}
return analyticsInfo;
}
}