coveo-search-ui
Version:
Coveo JavaScript Search Framework
900 lines (797 loc) • 37.3 kB
text/typescript
import 'styling/_Result';
import 'styling/_ResultFrame';
import 'styling/_ResultList';
import { compact, contains, each, flatten, map, without, uniqueId, find, unique, chain } from 'underscore';
import {
IBuildingQueryEventArgs,
IDuringQueryEventArgs,
IFetchMoreSuccessEventArgs,
INewQueryEventArgs,
IQueryErrorEventArgs,
IQuerySuccessEventArgs,
QueryEvents
} from '../../events/QueryEvents';
import { IResultLayoutPopulateArgs, ResultLayoutEvents } from '../../events/ResultLayoutEvents';
import {
IChangeLayoutEventArgs,
IDisplayedNewResultEventArgs,
IDisplayedNewResultsEventArgs,
ResultListEvents
} from '../../events/ResultListEvents';
import { exportGlobally } from '../../GlobalExports';
import { Assert } from '../../misc/Assert';
import { Defer } from '../../misc/Defer';
import { MODEL_EVENTS } from '../../models/Model';
import { QUERY_STATE_ATTRIBUTES } from '../../models/QueryStateModel';
import { IQueryResult } from '../../rest/QueryResult';
import { IQueryResults } from '../../rest/QueryResults';
import { DeviceUtils } from '../../utils/DeviceUtils';
import { $$, Doc, Win } from '../../utils/Dom';
import { DomUtils } from '../../utils/DomUtils';
import { Utils } from '../../utils/Utils';
import { analyticsActionCauseList, IAnalyticsNoMeta } from '../Analytics/AnalyticsActionListMeta';
import { Component } from '../Base/Component';
import { IComponentBindings } from '../Base/ComponentBindings';
import { ComponentOptions } from '../Base/ComponentOptions';
import { IInitializationParameters, IInitResult, Initialization } from '../Base/Initialization';
import { InitializationPlaceholder } from '../Base/InitializationPlaceholder';
import { TemplateComponentOptions } from '../Base/TemplateComponentOptions';
import { ResponsiveDefaultResultTemplate } from '../ResponsiveComponents/ResponsiveDefaultResultTemplate';
import { ValidLayout, RendererValidLayout } from '../ResultLayoutSelector/ValidLayout';
import { CoreHelpers } from '../Templates/CoreHelpers';
import { DefaultRecommendationTemplate } from '../Templates/DefaultRecommendationTemplate';
import { DefaultResultTemplate } from '../Templates/DefaultResultTemplate';
import { TableTemplate } from '../Templates/TableTemplate';
import { Template } from '../Templates/Template';
import { TemplateCache } from '../Templates/TemplateCache';
import { TemplateList } from '../Templates/TemplateList';
import { ResultContainer } from './ResultContainer';
import { ResultListCardRenderer } from './ResultListCardRenderer';
import { ResultListRenderer } from './ResultListRenderer';
import { ResultListTableRenderer } from './ResultListTableRenderer';
import ResultLayoutSelectorModule = require('../ResultLayoutSelector/ResultLayoutSelector');
import { IResultListOptions } from './ResultListOptions';
import { ResultListUtils } from '../../utils/ResultListUtils';
import { TemplateToHtml, ITemplateToHtml } from '../Templates/TemplateToHtml';
import { IQuery } from '../../rest/Query';
CoreHelpers.exportAllHelpersGlobally(window['Coveo']);
type WaitAnimation = 'fade' | 'spinner' | 'none';
/**
* The `ResultList` component is responsible for displaying query results by applying one or several result templates
* (see [Result Templates](https://docs.coveo.com/en/413/)).
*
* It is possible to include multiple `ResultList` components along with a single `ResultLayout`
* component in a search page to provide different result layouts (see
* [Result Layouts](https://docs.coveo.com/en/360/)).
*
* This component supports infinite scrolling (see the
* [`enableInfiniteScroll`]{@link ResultList.options.enableInfiniteScroll} option).
*/
export class ResultList extends Component {
private static getDefaultTemplate(e: HTMLElement): Template {
const template = ResultList.loadTemplatesFromCache();
if (template != null) {
return template;
}
const component = <ResultList>Component.get(e);
if (Coveo['Recommendation'] && component.searchInterface instanceof Coveo['Recommendation']) {
return new DefaultRecommendationTemplate();
}
return new DefaultResultTemplate();
}
private static loadTemplatesFromCache(): Template {
var pageTemplateNames = TemplateCache.getResultListTemplateNames();
if (pageTemplateNames.length > 0) {
return new TemplateList(compact(map(pageTemplateNames, templateName => TemplateCache.getTemplate(templateName))));
}
return null;
}
static ID = 'ResultList';
static doExport = () => {
exportGlobally({
ResultList: ResultList
});
};
/**
* The options for the ResultList
* @componentOptions
*/
static options: IResultListOptions = {
/**
* The element inside which to insert the rendered result templates.
*
* Performing a new query clears the content of this element.
*
* You can change the container by specifying its selector (e.g.,
* `data-result-container-selector='#someCssSelector'`).
*
* If you specify no value for this option, a `div` element will be dynamically created and appended to the result
* list. This element will then be used as a result container.
*/
resultsContainer: ComponentOptions.buildChildHtmlElementOption({ alias: 'resultContainerSelector' }),
resultTemplate: TemplateComponentOptions.buildTemplateOption({ defaultFunction: ResultList.getDefaultTemplate }),
/**
* The type of animation to display while waiting for a query to return.
*
* The possible values are:
* - `fade`: Fades out the current list of results while the query is executing.
* - `spinner`: Shows a spinning animation while the query is executing.
* - `none`: Use no animation during queries.
*
* See also the [`waitAnimationContainer`]{@link ResultList.options.waitAnimationContainer} option.
*
* @examples spinner
*/
waitAnimation: ComponentOptions.buildStringOption<WaitAnimation>({ defaultValue: 'none' }),
/**
* The element inside which to display the [`waitAnimation`]{@link ResultList.options.waitAnimation}.
*
* You can change this by specifying a CSS selector (e.g.,
* `data-wait-animation-container-selector='#someCssSelector'`).
*
* Defaults to the value of the [`resultsContainer`]{@link ResultList.options.resultsContainer} option.
*/
waitAnimationContainer: ComponentOptions.buildChildHtmlElementOption({
postProcessing: (value, options: IResultListOptions) => value || options.resultsContainer
}),
/**
* Whether to automatically retrieve an additional page of results and append it to the
* results that the `ResultList` is currently displaying when the user scrolls down to the bottom of the
* [`infiniteScrollContainer`]{@link ResultList.options.infiniteScrollContainer}.
*
* See also the [`infiniteScrollPageSize`]{@link ResultList.options.infiniteScrollPageSize} and
* [`enableInfiniteScrollWaitingAnimation`]{@link ResultList.options.enableInfiniteScrollWaitingAnimation} options.
*
* It is important to specify the `infiniteScrollContainer` option manually if you want the scrolling element to be
* something else than the default `window` element. Otherwise, you might find yourself in a strange state where the
* framework rapidly triggers multiple successive query.
*/
enableInfiniteScroll: ComponentOptions.buildBooleanOption({ defaultValue: false }),
/**
* The number of additional results to fetch when the user scrolls down to the bottom of the
* [`infiniteScrollContainer`]{@link ResultList.options.infiniteScrollContainer}.
*
* @examples 5
*/
infiniteScrollPageSize: ComponentOptions.buildNumberOption({
defaultValue: 10,
min: 1,
depend: 'enableInfiniteScroll'
}),
/**
* The element that triggers fetching additional results when the end user scrolls down to its bottom.
*
* You can change the container by specifying its selector (e.g.,
* `data-infinite-scroll-container-selector='#someCssSelector'`).
*
* By default, the framework uses the first vertically scrollable parent element it finds, starting from the
* `ResultList` element itself. A vertically scrollable element is an element whose CSS `overflow-y` attribute is
* `scroll`.
*
* This implies that if the framework can find no scrollable parent, it uses the `window` itself as a scrollable
* container.
*
* This heuristic is not perfect, for technical reasons. There are always some corner case CSS combination which the
* framework will not be able to correctly detect as 'scrollable'.
*
* It is highly recommended that you manually set this option if you wish something else than the `window` to be the
* scrollable element.
*/
infiniteScrollContainer: ComponentOptions.buildChildHtmlElementOption({
depend: 'enableInfiniteScroll',
defaultFunction: element => ComponentOptions.findParentScrolling(element)
}),
/**
* Whether to display the [`waitingAnimation`]{@link ResultList.options.waitAnimation} while fetching additional
* results.
*/
enableInfiniteScrollWaitingAnimation: ComponentOptions.buildBooleanOption({
depend: 'enableInfiniteScroll',
defaultValue: true
}),
mobileScrollContainer: ComponentOptions.buildSelectorOption({
defaultFunction: () => <HTMLElement>document.querySelector('.coveo-results-column')
}),
/**
* Whether the `ResultList` should scan its result templates to discover which fields it must request to
* be able to render all results.
*
* Setting this option to `true` ensures that the Coveo Search API does not return fields that are unnecessary for
* the UI to function.
*
* If you set this option to `true`, the fields referenced in your result folding templates won't be automatically resolved.
* In such a case, you must manually specify those fields using the [`fieldsToInclude`]{@link ResultList.options.fieldsToInclude} option.
*
* **Notes:**
*
* - Many interfaces created with the JavaScript Search Interface Editor explicitly set this option to `true`.
* - You cannot set this option to `true` in the Coveo for Sitecore integration.
*/
autoSelectFieldsToInclude: ComponentOptions.buildBooleanOption({ defaultValue: false }),
/**
* A list of fields to include in the query results.
*
* If you set the [`autoSelectFieldsToInclude`]{@link ResultList.options.autoSelectFieldsToInclude} option to
* `true`, the Coveo Search API returns the fields you specify for this option (if those fields are available) in
* addition to the fields which the `ResultList` automatically requests. Note that the `autoSelectFieldsToInclude`
* option doesn't automatically request fields for folding templates; in such a case, you must manually specify
* fields using this option to avoid empty results.
*
* If you set the [`autoSelectFieldsToInclude`]{@link ResultList.options.autoSelectFieldsToInclude} option to
* `false`, the Coveo Search API only returns the fields you specify for this option (if those fields are
* available).
*
* If you set the [`autoSelectFieldsToInclude`]{@link ResultList.options.autoSelectFieldsToInclude} option to
* `false` and leave this option undefined, the Coveo Search API returns all available fields.
*/
fieldsToInclude: ComponentOptions.buildFieldsOption({ includeInResults: true }),
/**
* Specifies the layout to use when displaying results in this `ResultList` (see
* [Result Layouts](https://docs.coveo.com/en/360/)). Specifying a value for this option automatically
* populates a [`ResultLayout`]{@link ResultLayout} component with a switcher for the layout.
*
* For example, if there are two `ResultList` components in the page, one with its `layout` set to `list` and the
* other with the same option set to `card`, then the `ResultLayout` component will render two buttons respectively
* entitled **List** and **Card**.
*
* See the [`ValidLayout`]{@link ValidLayout} type for the list of possible values.
*
* @examples card
*/
layout: ComponentOptions.buildStringOption<ValidLayout>({
defaultValue: 'list',
required: true
}),
/**
* Whether to scroll back to the top of the page when the end-user:
* - interacts with a facet when [`infiniteScrollContainer`]{@link ResultList.options.infiniteScrollContainer} is set to `true`
* - interacts with the [`Pager`]{@link Pager} component when [`infiniteScrollContainer`]{@link ResultList.options.infiniteScrollContainer} is set to `false`
*
* **Note:** Setting this option to `false` has no effect on dynamic facets. To disable this behavior on a `DynamicFacet` component, you must set its own [`enableScrollToTop`]{@link DynamicFacet.options.enableScrollToTop} option to `false`.
*
* @availablesince [July 2019 Release (v2.6459)](https://docs.coveo.com/en/2938/)
*/
enableScrollToTop: ComponentOptions.buildBooleanOption({
defaultValue: true
})
};
public static resultCurrentlyBeingRendered: IQueryResult = null;
public currentlyDisplayedResults: IQueryResult[] = [];
private fetchingMoreResults: Promise<IQueryResults>;
private reachedTheEndOfResults = false;
private disableLayoutChange = false;
private renderer: ResultListRenderer;
private resultContainer: ResultContainer;
// This variable serves to block some setup where the framework fails to correctly identify the "real" scrolling container.
// Since it's not technically feasible to correctly identify the scrolling container in every possible scenario without some very complex logic, we instead try to add some kind of mechanism to
// block runaway requests where UI will keep asking more results in the index, eventually bringing the browser to it's knee.
// Those successive request are needed in "displayMoreResults" to ensure we fill the scrolling container correctly.
// Since the container is not identified correctly, it is never "full", so we keep asking for more.
// It is reset every time the user actually scroll the container manually.
private successiveScrollCount = 0;
private static MAX_AMOUNT_OF_SUCESSIVE_REQUESTS = 5;
/**
* Creates a new `ResultList` component. Binds various event related to queries (e.g., on querySuccess ->
* renderResults). Binds scroll event if the [`enableInfiniteScroll`]{@link ResultList.options.enableInfiniteScroll}
* option is `true`.
* @param element The HTMLElement on which to instantiate the component.
* @param options The options for the `ResultList` 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).
* @param elementClassId The class that this component should instantiate. Components that extend the base ResultList
* use this. Default value is `CoveoResultList`.
*/
constructor(
public element: HTMLElement,
public options?: IResultListOptions,
public bindings?: IComponentBindings,
elementClassId: string = ResultList.ID
) {
super(element, elementClassId, bindings);
this.options = ComponentOptions.initComponentOptions(element, ResultList, options);
Assert.exists(element);
Assert.exists(this.options);
Assert.exists(this.options.resultTemplate);
Assert.exists(this.options.infiniteScrollContainer);
this.showOrHideElementsDependingOnState(false, false);
this.addListeners();
this.resultContainer = this.initResultContainer();
Assert.exists(this.options.resultsContainer);
this.initWaitAnimationContainer();
Assert.exists(this.options.waitAnimationContainer);
this.setupTemplatesVersusLayouts();
$$(this.root).on(ResultLayoutEvents.populateResultLayout, (e, args: IResultLayoutPopulateArgs) =>
args.layouts.push(this.options.layout)
);
this.setupRenderer();
this.makeElementFocusable();
this.ensureHasId();
}
private addListeners() {
this.bind.onRootElement<INewQueryEventArgs>(QueryEvents.newQuery, (args: INewQueryEventArgs) => this.handleNewQuery());
this.bind.onRootElement<IBuildingQueryEventArgs>(QueryEvents.buildingQuery, (args: IBuildingQueryEventArgs) =>
this.handleBuildingQuery(args)
);
this.bind.onRootElement<IQuerySuccessEventArgs>(QueryEvents.querySuccess, (args: IQuerySuccessEventArgs) =>
this.handleQuerySuccess(args)
);
this.bind.onRootElement<IFetchMoreSuccessEventArgs>(QueryEvents.fetchMoreSuccess, (args: IFetchMoreSuccessEventArgs) =>
this.handleFetchMoreSuccess(args)
);
this.bind.onRootElement<IDuringQueryEventArgs>(QueryEvents.duringQuery, (args: IDuringQueryEventArgs) => this.handleDuringQuery());
this.bind.onRootElement<IQueryErrorEventArgs>(QueryEvents.queryError, (args: IQueryErrorEventArgs) => this.handleQueryError());
$$(this.root).on(ResultListEvents.changeLayout, (e: Event, args: IChangeLayoutEventArgs) => this.handleChangeLayout(args));
if (this.options.enableInfiniteScroll) {
this.addInfiniteScrollListeners();
} else {
this.addPagerListeners();
}
}
private addInfiniteScrollListeners() {
this.bind.on(<HTMLElement>this.options.infiniteScrollContainer, 'scroll', () => {
this.successiveScrollCount = 0;
this.handleScrollOfResultList();
});
this.bind.onRootElement(QueryEvents.deferredQuerySuccess, () => {
setTimeout(() => {
this.scrollToTopIfEnabled();
}, 0);
});
}
private addPagerListeners() {
this.bind.onQueryState(MODEL_EVENTS.CHANGE_ONE, QUERY_STATE_ATTRIBUTES.FIRST, () => {
this.bind.oneRootElement(QueryEvents.deferredQuerySuccess, () => {
setTimeout(() => {
this.scrollToTopIfEnabled();
}, 0);
});
});
}
/**
* Get the fields needed to be automatically included in the query for this result list.
* @returns {string[]}
*/
public getAutoSelectedFieldsToInclude(): string[] {
return chain(this.options.resultTemplate.getFields()).concat(this.getMinimalFieldsToInclude()).compact().unique().value();
}
private setupTemplatesVersusLayouts() {
const layoutClassToAdd = `coveo-${this.options.layout}-layout-container`;
this.resultContainer.addClass(layoutClassToAdd);
if (this.options.layout === 'table') {
this.options.resultTemplate = new TableTemplate((<TemplateList>this.options.resultTemplate).templates || []);
}
// A TemplateList is the scenario where the result template are directly embedded inside the ResultList
// This is the typical scenario when a page gets created by the interface editor, for example.
// In that case, we try to stick closely that what is actually configured inside the page, and do no "special magic".
// Stick to the "hardcoded" configuration present in the page.
// We only add the correct layout options if it has not been set manually.
if (this.options.resultTemplate instanceof TemplateList) {
each((<TemplateList>this.options.resultTemplate).templates, (tmpl: Template) => {
if (!tmpl.layout) {
tmpl.layout = <ValidLayout>this.options.layout;
}
});
} else if (this.options.resultTemplate instanceof DefaultResultTemplate && this.options.layout == 'list') {
ResponsiveDefaultResultTemplate.init(this.root, this, {});
}
}
/**
* Empties the current result list content and appends the given array of HTMLElement.
*
* Can append to existing elements in the result list, or replace them.
*
* Triggers the `newResultsDisplayed` and `newResultDisplayed` events.
* @param resultsElement
* @param append
*/
public renderResults(resultElements: HTMLElement[], append = false): Promise<void> {
if (!append) {
this.resultContainer.empty();
}
return this.renderer
.renderResults(resultElements, append, this.triggerNewResultDisplayed.bind(this))
.then(() => this.triggerNewResultsDisplayed());
}
/**
* Builds and returns an array of HTMLElement with the given result set.
* @param results the result set to build an array of HTMLElement from.
*/
public buildResults(results: IQueryResults): Promise<HTMLElement[]> {
const layout = <RendererValidLayout>this.options.layout;
return this.templateToHtml.buildResults(results, layout, this.currentlyDisplayedResults);
}
/**
* Builds and returns an HTMLElement for the given result.
* @param result the result to build an HTMLElement from.
* @returns {HTMLElement}
*/
public buildResult(result: IQueryResult): Promise<HTMLElement> {
const layout = <RendererValidLayout>this.options.layout;
return this.templateToHtml.buildResult(result, layout, this.currentlyDisplayedResults);
}
/**
* Executes a query to fetch new results. After the query returns, renders the new results.
*
* Asserts that there are more results to display by verifying whether the last query has returned as many results as
* requested.
*
* Asserts that the `ResultList` is not currently fetching results.
* @param count The number of results to fetch and display.
*/
public displayMoreResults(count: number) {
Assert.isLargerOrEqualsThan(1, count);
if (this.isCurrentlyFetchingMoreResults()) {
this.logger.warn("Ignoring request to display more results since we're already doing so");
return;
}
if (!this.hasPotentiallyMoreResultsToDisplay()) {
this.logger.warn("Ignoring request to display more results since we know there aren't more to display");
return;
}
if (this.options.enableInfiniteScrollWaitingAnimation) {
this.showWaitingAnimationForInfiniteScrolling();
}
return this.fetchAndRenderMoreResults(count);
}
public get templateToHtml() {
const templateToHtmlArgs: ITemplateToHtml = {
resultTemplate: this.options.resultTemplate,
searchInterface: this.searchInterface,
queryStateModel: this.queryStateModel
};
return new TemplateToHtml(templateToHtmlArgs);
}
/**
* Gets the list of currently displayed result.
* @returns {IQueryResult[]}
*/
public getDisplayedResults(): IQueryResult[] {
return this.currentlyDisplayedResults;
}
/**
* Gets the list of currently displayed result HTMLElement.
* @returns {HTMLElement[]}
*/
public getDisplayedResultsElements(): HTMLElement[] {
return this.resultContainer.getResultElements();
}
public enable(): void {
this.disableLayoutChange = false;
if (this.resultLayoutSelectors.length > 0) {
this.enableBasedOnActiveLayouts();
} else {
super.enable();
$$(this.element).removeClass('coveo-hidden');
}
}
public disable(): void {
super.disable();
const otherLayoutsStillActive = map(this.otherResultLists, otherResultList => otherResultList.options.layout);
if (!contains(otherLayoutsStillActive, this.options.layout) && !this.disableLayoutChange) {
each(this.resultLayoutSelectors, resultLayoutSelector => {
resultLayoutSelector.disableLayouts([this.options.layout] as ValidLayout[]);
});
}
this.disableLayoutChange = false;
$$(this.element).addClass('coveo-hidden');
}
protected autoCreateComponentsInsideResult(element: HTMLElement, result: IQueryResult): IInitResult {
return this.templateToHtml.autoCreateComponentsInsideResult(element, result);
}
protected triggerNewResultDisplayed(result: IQueryResult, resultElement: HTMLElement) {
const args: IDisplayedNewResultEventArgs = {
result: result,
item: resultElement
};
$$(this.element).trigger(ResultListEvents.newResultDisplayed, args);
}
protected triggerNewResultsDisplayed() {
const args: IDisplayedNewResultsEventArgs = {
isInfiniteScrollEnabled: this.options.enableInfiniteScroll
};
$$(this.element).trigger(ResultListEvents.newResultsDisplayed, args);
}
private async fetchAndRenderMoreResults(count: number): Promise<IQueryResults> {
this.fetchingMoreResults = this.queryController.fetchMore(count);
try {
const data = await this.fetchingMoreResults;
Assert.exists(data);
this.usageAnalytics.logCustomEvent<IAnalyticsNoMeta>(analyticsActionCauseList.pagerScrolling, {}, this.element);
this.renderNewResults(data);
this.resetStateAfterFetchingMoreResults();
return data;
} catch (e) {
this.resetStateAfterFetchingMoreResults();
return Promise.reject(e);
}
}
private async renderNewResults(data: IQueryResults) {
const elements = await this.buildResults(data);
this.renderResults(elements, true);
this.currentlyDisplayedResults.push(...data.results);
}
private resetStateAfterFetchingMoreResults() {
this.hideWaitingAnimationForInfiniteScrolling();
this.fetchingMoreResults = undefined;
Defer.defer(() => {
this.successiveScrollCount++;
if (this.successiveScrollCount <= ResultList.MAX_AMOUNT_OF_SUCESSIVE_REQUESTS) {
this.handleScrollOfResultList();
} else {
this.logger.info(
`Result list has triggered 5 consecutive queries to try and fill up the scrolling container, but it is still unable to do so.
Try explicitly setting the 'data-infinite-scroll-container-selector' option on the result list. See: https://coveo.github.io/search-ui/components/resultlist.html#options.infinitescrollcontainer`
);
}
});
}
private handleDuringQuery() {
this.logger.trace('Emptying the result container');
this.cancelFetchingMoreResultsIfNeeded();
this.showWaitingAnimation();
this.showOrHideElementsDependingOnState(false, false);
}
private handleQueryError() {
this.hideWaitingAnimation();
this.resultContainer.empty();
this.currentlyDisplayedResults = [];
this.reachedTheEndOfResults = true;
}
private handleQuerySuccess(data: IQuerySuccessEventArgs) {
Assert.exists(data);
Assert.exists(data.results);
const results = data.results;
this.logger.trace('Received query results from new query', results);
this.hideWaitingAnimation();
ResultList.resultCurrentlyBeingRendered = undefined;
this.currentlyDisplayedResults = [];
this.buildResults(data.results).then(async (elements: HTMLElement[]) => {
await this.renderResults(elements);
this.showOrHideElementsDependingOnState(true, this.currentlyDisplayedResults.length != 0);
if (DeviceUtils.isMobileDevice() && this.options.mobileScrollContainer != undefined) {
this.options.mobileScrollContainer.scrollTop = 0;
}
if (this.options.enableInfiniteScroll && results.results.length == data.queryBuilder.numberOfResults) {
// This will check right away if we need to add more results to make the scroll container full & scrolling.
this.scrollToTopIfEnabled();
this.handleScrollOfResultList();
}
});
this.handleQueryOrFetchMoreSuccess(data.query, results);
}
private handleFetchMoreSuccess(data: IFetchMoreSuccessEventArgs) {
this.handleQueryOrFetchMoreSuccess(data.query, data.results);
}
private handleQueryOrFetchMoreSuccess(query: IQuery, results: IQueryResults) {
const firstResultOfNextPage = (query.firstResult || 0) + results.results.length;
this.reachedTheEndOfResults = firstResultOfNextPage >= results.totalCountFiltered;
}
private handleScrollOfResultList() {
if (this.isCurrentlyFetchingMoreResults() || !this.options.enableInfiniteScroll) {
return;
}
if (this.isScrollingOfResultListAlmostAtTheBottom() && this.hasPotentiallyMoreResultsToDisplay()) {
this.displayMoreResults(this.options.infiniteScrollPageSize);
}
}
private scrollToTopIfEnabled() {
if (!this.options.enableScrollToTop) {
return;
}
ResultListUtils.scrollToTop(this.root);
}
private handleNewQuery() {
$$(this.element).removeClass('coveo-hidden');
ResultList.resultCurrentlyBeingRendered = undefined;
}
private get otherResultLists() {
const allResultLists = this.searchInterface.getComponents(ResultList.ID) as ResultList[];
return without(allResultLists, this);
}
private get resultLayoutSelectors() {
return this.searchInterface.getComponents('ResultLayoutSelector') as ResultLayoutSelectorModule.ResultLayoutSelector[];
}
protected handleBuildingQuery(args: IBuildingQueryEventArgs) {
args.queryBuilder.addFieldsToInclude(this.getMinimalFieldsToInclude());
if (this.options.fieldsToInclude != null) {
// remove the @
args.queryBuilder.addFieldsToInclude(map(this.options.fieldsToInclude, field => field.substr(1)));
}
if (this.options.autoSelectFieldsToInclude) {
const otherFields = flatten(
map(this.otherResultLists, otherResultList => {
return otherResultList.getAutoSelectedFieldsToInclude();
})
);
args.queryBuilder.addRequiredFields(unique(otherFields.concat(this.getAutoSelectedFieldsToInclude())));
args.queryBuilder.includeRequiredFields = true;
}
}
protected handleChangeLayout(args: IChangeLayoutEventArgs) {
if (args.layout === this.options.layout) {
this.disableLayoutChange = false;
this.enable();
this.options.resultTemplate.layout = <ValidLayout>this.options.layout;
if (args.results) {
// Prevent flickering when switching to a new layout that is empty
// add a temporary placeholder, the same that is used on initialization
if (this.resultContainer.isEmpty()) {
new InitializationPlaceholder(this.root).withVisibleRootElement().withPlaceholderForResultList();
}
Defer.defer(async () => {
const elements = await this.buildResults(args.results);
this.renderResults(elements);
this.showOrHideElementsDependingOnState(true, this.currentlyDisplayedResults.length !== 0);
});
}
} else {
this.disableLayoutChange = true;
this.disable();
this.resultContainer.empty();
}
}
private isCurrentlyFetchingMoreResults(): boolean {
return Utils.exists(this.fetchingMoreResults);
}
private getMinimalFieldsToInclude() {
// these fields are needed for analytics click event
return ['author', 'language', 'urihash', 'objecttype', 'collection', 'source', 'language', 'permanentid'];
}
private isScrollingOfResultListAlmostAtTheBottom(): boolean {
// this is in a try catch because the unit test fail otherwise (Window does not exist for phantom js in the console)
let isWindow;
try {
isWindow = this.options.infiniteScrollContainer instanceof Window;
} catch (e) {
isWindow = false;
}
return isWindow ? this.isScrollAtBottomForWindowElement() : this.isScrollAtBottomForHtmlElement();
}
private isScrollAtBottomForWindowElement() {
const win = new Win(window);
const windowHeight = win.height();
const scrollTop = win.scrollY();
const bodyHeight = new Doc(document).height();
return bodyHeight - (windowHeight + scrollTop) < windowHeight / 2;
}
private isScrollAtBottomForHtmlElement() {
const el = <HTMLElement>this.options.infiniteScrollContainer;
const elementHeight = el.clientHeight;
const scrollHeight = el.scrollHeight;
const bottomPosition = el.scrollTop + elementHeight;
return scrollHeight - bottomPosition < elementHeight / 2;
}
public hasPotentiallyMoreResultsToDisplay(): boolean {
return this.currentlyDisplayedResults.length > 0 && !this.reachedTheEndOfResults;
}
private cancelFetchingMoreResultsIfNeeded() {
if (this.isCurrentlyFetchingMoreResults()) {
this.logger.trace('Cancelling fetching more results');
Promise.reject(this.fetchingMoreResults);
this.fetchingMoreResults = undefined;
}
}
private showOrHideElementsDependingOnState(hasQuery: boolean, hasResults: boolean) {
const showIfQuery = $$(this.element).findAll('.coveo-show-if-query');
const showIfNoQuery = $$(this.element).findAll('.coveo-show-if-no-query');
const showIfResults = $$(this.element).findAll('.coveo-show-if-results');
const showIfNoResults = $$(this.element).findAll('.coveo-show-if-no-results');
each(showIfQuery, (s: HTMLElement) => {
$$(s).toggle(hasQuery);
});
each(showIfNoQuery, (s: HTMLElement) => {
$$(s).toggle(!hasQuery);
});
each(showIfResults, (s: HTMLElement) => {
$$(s).toggle(hasQuery && hasResults);
});
each(showIfNoResults, (s: HTMLElement) => {
$$(s).toggle(hasQuery && !hasResults);
});
}
private get waitAnimation() {
return this.options.waitAnimation.toLowerCase() as WaitAnimation;
}
private showWaitingAnimation() {
switch (this.waitAnimation) {
case 'fade':
$$(this.options.waitAnimationContainer).addClass('coveo-fade-out');
break;
case 'spinner':
this.resultContainer.hideChildren();
if ($$(this.options.waitAnimationContainer).find('.coveo-wait-animation') == undefined) {
this.options.waitAnimationContainer.appendChild(DomUtils.getBasicLoadingAnimation());
}
break;
}
}
private hideWaitingAnimation() {
switch (this.waitAnimation) {
case 'fade':
$$(this.options.waitAnimationContainer).removeClass('coveo-fade-out');
break;
case 'spinner':
const spinner = $$(this.options.waitAnimationContainer).find('.coveo-loading-spinner');
if (spinner) {
$$(spinner).detach();
}
break;
}
}
private showWaitingAnimationForInfiniteScrolling() {
const spinner = DomUtils.getLoadingSpinner();
if (this.options.layout == 'card' && this.options.enableInfiniteScroll) {
const previousSpinnerContainer = $$(this.options.waitAnimationContainer).findAll('.coveo-loading-spinner-container');
each(previousSpinnerContainer, previousSpinner => $$(previousSpinner).remove());
const spinnerContainer = $$('div', {
className: 'coveo-loading-spinner-container'
});
spinnerContainer.append(spinner);
this.options.waitAnimationContainer.appendChild(spinnerContainer.el);
} else {
this.options.waitAnimationContainer.appendChild(DomUtils.getLoadingSpinner());
}
}
private hideWaitingAnimationForInfiniteScrolling() {
const spinners = $$(this.options.waitAnimationContainer).findAll('.coveo-loading-spinner');
const containers = $$(this.options.waitAnimationContainer).findAll('.coveo-loading-spinner-container');
each(spinners, spinner => $$(spinner).remove());
each(containers, container => $$(container).remove());
}
private initResultContainer(): ResultContainer {
if (!this.options.resultsContainer) {
const elemType = this.options.layout === 'table' ? 'table' : 'div';
this.options.resultsContainer = $$(elemType, { className: 'coveo-result-list-container' }).el;
this.initResultContainerAddToDom();
}
return new ResultContainer(this.options.resultsContainer, this.searchInterface);
}
protected initResultContainerAddToDom() {
this.element.appendChild(this.options.resultsContainer);
}
private initWaitAnimationContainer() {
if (!this.options.waitAnimationContainer) {
this.options.waitAnimationContainer = this.resultContainer.el;
}
}
private setupRenderer() {
const initParameters: IInitializationParameters = {
options: this.searchInterface.options.originalOptionsObject,
bindings: this.bindings
};
const autoCreateComponentsFn = (elem: HTMLElement) => Initialization.automaticallyCreateComponentsInside(elem, initParameters);
switch (this.options.layout) {
case 'card':
this.renderer = new ResultListCardRenderer(this.options, autoCreateComponentsFn);
break;
case 'table':
this.renderer = new ResultListTableRenderer(this.options, autoCreateComponentsFn);
break;
case 'list':
default:
this.renderer = new ResultListRenderer(this.options, autoCreateComponentsFn);
break;
}
}
private makeElementFocusable() {
$$(this.element).setAttribute('tabindex', '-1');
}
private ensureHasId() {
const currentId = this.element.id;
if (currentId === '') {
this.element.id = uniqueId('coveo-result-list');
}
}
private enableBasedOnActiveLayouts() {
// We should try to enable a result list only when the result layout selector currently allow that result list layout to be displayed.
each(this.resultLayoutSelectors, resultLayoutSelector => {
const found = find(resultLayoutSelector.activeLayouts, (activeLayout, activeLayoutKey) => activeLayoutKey == this.options.layout);
if (found) {
super.enable();
resultLayoutSelector.enableLayouts([this.options.layout] as ValidLayout[]);
$$(this.element).removeClass('coveo-hidden');
}
});
}
}
Initialization.registerAutoCreateComponent(ResultList);