coveo-search-ui
Version:
Coveo JavaScript Search Framework
496 lines (424 loc) • 18.2 kB
text/typescript
import 'styling/_Pager';
import {
IBuildingQueryEventArgs,
INewQueryEventArgs,
INoResultsEventArgs,
IQuerySuccessEventArgs,
QueryEvents
} from '../../events/QueryEvents';
import { ResultListEvents } from '../../events/ResultListEvents';
import { exportGlobally } from '../../GlobalExports';
import { Assert } from '../../misc/Assert';
import { IAttributeChangedEventArg, MODEL_EVENTS } from '../../models/Model';
import { QueryStateModel, QUERY_STATE_ATTRIBUTES } from '../../models/QueryStateModel';
import { l } from '../../strings/Strings';
import { AccessibleButton } from '../../utils/AccessibleButton';
import { DeviceUtils } from '../../utils/DeviceUtils';
import { $$ } from '../../utils/Dom';
import { ResultListUtils } from '../../utils/ResultListUtils';
import { SVGDom } from '../../utils/SVGDom';
import { SVGIcons } from '../../utils/SVGIcons';
import { analyticsActionCauseList, IAnalyticsActionCause, IAnalyticsPagerMeta } from '../Analytics/AnalyticsActionListMeta';
import { Component } from '../Base/Component';
import { IComponentBindings } from '../Base/ComponentBindings';
import { ComponentOptions } from '../Base/ComponentOptions';
import { Initialization } from '../Base/Initialization';
export interface IPagerOptions {
numberOfPages: number;
enableNavigationButton: boolean;
maxNumberOfPages: number;
maximumNumberOfResultsFromIndex: number;
}
/**
* The Pager component attaches itself to a `div` element and renders widgets that allow the end user to navigate
* through the different result pages.
*
* This component takes care of triggering a query with the correct result range whenever the end user selects a page or
* uses the navigation buttons (**Previous** and **Next**).
*/
export class Pager extends Component {
static ID = 'Pager';
static doExport = () => {
exportGlobally({
Pager: Pager
});
};
/**
* The options for the Pager
* @componentOptions
*/
static options: IPagerOptions = {
/**
* Specifies how many page links to display in the pager.
*
* Default value is `5` on a desktop computers and `3` on a mobile device. Minimum value is `1`.
*/
numberOfPages: ComponentOptions.buildNumberOption({
defaultFunction: () => {
if (DeviceUtils.isMobileDevice()) {
return 3;
} else {
return 5;
}
},
min: 1
}),
/**
* Specifies whether the **Previous** and **Next** buttons should appear at each end of the pager when appropriate.
*
* The default value is `true`.
*/
enableNavigationButton: ComponentOptions.buildBooleanOption({ defaultValue: true }),
/**
* Specifies the maximum number of pages to display if enough results are available.
*
* This property is typically set when the default number of accessible results from the index has been changed from its default value of `1000` (10 results per page X 100 `maxNumberOfPages`).
* Default value is `100`
*
* @deprecated This is a deprecated option. The `Pager` now automatically adapts itself on each new query, so you no longer need to specify a value for this option. However, if the default maximum number of accessible results value was changed on your Coveo index, you should use the [`maximumNumberOfResultsFromIndex`]{@link Pager.options.maximumNumberOfResultsFromIndex} option to specify the new value.
*/
maxNumberOfPages: ComponentOptions.buildNumberOption({
defaultValue: undefined,
deprecated:
'This is a deprecated option. The pager will automatically adapt itself on each new query. You no longer need to specify this option. Use maximumNumberOfResultsFromIndex instead.'
}),
/**
* Specifies the maximum number of results that the index can return for any query.
*
* Default value is `1000` in a Coveo index.
*
* If this value was modified in your Coveo index, you must specify the new value in this option for the Pager component to work properly
*/
maximumNumberOfResultsFromIndex: ComponentOptions.buildNumberOption({
defaultValue: 1000
})
};
private listenToQueryStateChange = true;
private ignoreNextQuerySuccess = false;
private _currentPage: number;
// The normal behavior of this component is to reset to page 1 when a new
// query is performed by other components (i.e. not pagers).
//
// This behavior is overridden when the 'first' state is
// programmatically modified.
private needToReset = true;
private list: HTMLElement;
/**
* Creates a new Pager. Binds multiple query events ({@link QueryEvents.newQuery}, {@link QueryEvents.buildingQuery},
* {@link QueryEvents.querySuccess}, {@link QueryEvents.queryError} and {@link QueryEvents.noResults}. Renders itself
* on every query success.
* @param element The HTMLElement on which to instantiate the component (normally a `div`).
* @param options The options for the Pager 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?: IPagerOptions, bindings?: IComponentBindings) {
super(element, Pager.ID, bindings);
this.options = ComponentOptions.initComponentOptions(element, Pager, options);
this.currentPage = 1;
this.bind.onRootElement(QueryEvents.newQuery, (args: INewQueryEventArgs) => this.handleNewQuery(args));
this.bind.onRootElement(QueryEvents.buildingQuery, (args: IBuildingQueryEventArgs) => this.handleBuildingQuery(args));
this.bind.onRootElement(QueryEvents.querySuccess, (args: IQuerySuccessEventArgs) => this.handleQuerySuccess(args));
this.bind.onRootElement(QueryEvents.queryError, () => this.handleQueryError());
this.bind.onRootElement(QueryEvents.noResults, (args: INoResultsEventArgs) => this.handleNoResults(args));
this.bind.onQueryState(MODEL_EVENTS.CHANGE_ONE, QUERY_STATE_ATTRIBUTES.FIRST, (data: IAttributeChangedEventArg) =>
this.handleQueryStateFirstResultChanged(data)
);
this.bind.onQueryState(MODEL_EVENTS.CHANGE_ONE, QUERY_STATE_ATTRIBUTES.NUMBER_OF_RESULTS, (data: IAttributeChangedEventArg) =>
this.handleQueryStateNumberOfResultsPerPageChanged(data)
);
this.addAlwaysActiveListeners();
this.addAccessibilityAttributes();
this.list = $$('ul', {
className: 'coveo-pager-list'
}).el;
element.appendChild(this.list);
}
/**
* The current page (1-based index).
*/
public get currentPage(): number {
return this._currentPage;
}
public set currentPage(value: number) {
let sanitizedValue = value;
if (isNaN(value)) {
this.logger.warn(`Unable to set pager current page to an invalid value: ${value}. Resetting to 1.`);
sanitizedValue = 1;
}
sanitizedValue = Math.max(Math.min(sanitizedValue, this.getMaxNumberOfPagesForCurrentResultsPerPage()), 1);
sanitizedValue = Math.floor(sanitizedValue);
this._currentPage = sanitizedValue;
}
/**
* Sets the current page, then executes a query.
*
* Also logs an event in the usage analytics (`pageNumber` by default) with the new current page number as meta data.
*
* @param pageNumber The page number to navigate to.
* @param analyticCause The event to log in the usage analytics.
*/
public setPage(pageNumber: number, analyticCause: IAnalyticsActionCause = analyticsActionCauseList.pagerNumber) {
Assert.exists(pageNumber);
this.currentPage = pageNumber;
this.updateQueryStateModel(this.getFirstResultNumber(this.currentPage));
this.usageAnalytics.logCustomEvent<IAnalyticsPagerMeta>(analyticCause, { pagerNumber: this.currentPage }, this.element);
this.queryController.executeQuery({
ignoreWarningSearchEvent: true,
keepLastSearchUid: true,
origin: this
});
}
/**
* Navigates to the previous page, then executes a query.
*
* Also logs the `pagePrevious` event in the usage analytics with the new current page number as meta data.
*/
public previousPage() {
this.setPage(this.currentPage - 1, analyticsActionCauseList.pagerPrevious);
}
/**
* Navigates to the next page, then executes a query.
*
* Also logs the `pageNext` event in the usage analytics with the new current page number as meta data.
*/
public nextPage() {
this.setPage(this.currentPage + 1, analyticsActionCauseList.pagerNext);
}
private addAccessibilityAttributes() {
if (!this.element.getAttribute('role')) {
this.element.setAttribute('role', 'navigation');
}
if (!this.element.hasAttribute('aria-label')) {
this.element.setAttribute('aria-label', l('Pagination'));
}
}
private addAlwaysActiveListeners() {
this.searchInterface.element.addEventListener(ResultListEvents.newResultsDisplayed, () =>
ResultListUtils.hideIfInfiniteScrollEnabled(this)
);
}
private getMaxNumberOfPagesForCurrentResultsPerPage() {
return Math.ceil(this.options.maximumNumberOfResultsFromIndex / this.searchInterface.resultsPerPage);
}
private handleNewQuery(data: INewQueryEventArgs) {
const triggeredByPagerOrDebugMode = data && data.origin && (data.origin.type == Pager.ID || data.origin.type == 'Debug');
if (this.needToReset && !triggeredByPagerOrDebugMode) {
this.currentPage = 1;
this.updateQueryStateModel(this.getFirstResultNumber(this.currentPage));
}
this.needToReset = true;
}
private updateQueryStateModel(attrValue: number) {
this.listenToQueryStateChange = false;
this.queryStateModel.set(QueryStateModel.attributesEnum.first, attrValue);
this.listenToQueryStateChange = true;
}
private handleQueryError() {
this.reset();
}
private handleQuerySuccess(data: IQuerySuccessEventArgs) {
this.reset();
if (this.ignoreNextQuerySuccess) {
this.ignoreNextQuerySuccess = false;
} else {
Assert.isNotUndefined(data);
const firstResult = data.query.firstResult;
const count = data.results.totalCountFiltered;
const pagerBoundary = this.computePagerBoundary(firstResult, count);
this.currentPage = pagerBoundary.currentPage;
if (pagerBoundary.end - pagerBoundary.start > 0) {
for (let i = pagerBoundary.start; i <= pagerBoundary.end; i++) {
const page = i;
const isCurrentPage = page === this.currentPage;
const button = $$(
'span',
{
className: 'coveo-pager-list-item-text coveo-pager-anchor',
tabindex: 0,
ariaPressed: `${isCurrentPage}`
},
i.toString(10)
).el;
const listItem = $$('li', {
className: 'coveo-pager-list-item'
}).el;
if (isCurrentPage) {
$$(listItem).addClass('coveo-active');
button.setAttribute('aria-current', 'page');
}
const clickAction = (e: Event) => {
e.stopPropagation();
this.handleClickPage(page);
};
new AccessibleButton()
.withElement(button)
.withLabel(l('PageNumber', i.toString(10)))
.withClickAction(clickAction)
.withEnterKeyboardAction(clickAction)
.build();
$$(listItem).on('click', e => clickAction(e));
listItem.appendChild(button);
this.list.appendChild(listItem);
}
if (this.options.enableNavigationButton && pagerBoundary.lastResultPage > 1) {
this.renderNavigationButton(pagerBoundary);
}
}
}
}
private handleNoResults(data: INoResultsEventArgs) {
let lastValidPage;
if (data.results.totalCount > 0) {
// First scenario : The index returned less results than expected (because of folding).
// Recalculate the last valid page, and change to that new page.
const possibleValidPage = this.computePagerBoundary(data.results.totalCountFiltered, data.results.totalCount).lastResultPage;
if (this.currentPage > possibleValidPage) {
lastValidPage = possibleValidPage;
}
} else if (this.currentPage > this.getMaxNumberOfPagesForCurrentResultsPerPage()) {
// Second scenario : Someone tried to access a non-valid page by the URL for example, which is higher than the current possible with the number of
// possible results. The last valid page will be the maximum number of results avaiable from the index.
lastValidPage = this.getMaxNumberOfPagesForCurrentResultsPerPage();
}
// This needs to be deferred because we still want all the "querySuccess" callbacks the complete their execution
// before triggering/queuing the next query;
// if we cannot find a lastValidPage to go to, do nothing : this means it's a query that simply return nothing.
if (lastValidPage != null) {
this.currentPage = lastValidPage;
data.retryTheQuery = true;
this.needToReset = false;
this.ignoreNextQuerySuccess = false;
this.updateQueryStateModel(this.getFirstResultNumber(this.currentPage));
}
}
private reset() {
$$(this.list).empty();
}
private handleBuildingQuery(data: IBuildingQueryEventArgs) {
Assert.exists(data);
const eventArgs = this.getQueryEventArgs();
data.queryBuilder.firstResult = eventArgs.first;
// Set the number of results only if it was not already set by external code
// Most of the time this will be set by either : the SearchInterface with the resultsPerPage option
// Or by the ResultsPerPage component (so the end user decides).
// Pager will realistically never set this value itself.
if (data.queryBuilder.numberOfResults == null) {
data.queryBuilder.numberOfResults = eventArgs.count;
}
const maxResultNumber = data.queryBuilder.firstResult + data.queryBuilder.numberOfResults;
const numOfExcessResults = Math.max(0, maxResultNumber - this.options.maximumNumberOfResultsFromIndex);
data.queryBuilder.numberOfResults -= numOfExcessResults;
}
private computePagerBoundary(firstResult: number, totalCount: number) {
const resultPerPage = this.searchInterface.resultsPerPage;
const currentPage = Math.floor(firstResult / resultPerPage) + 1;
const lastPageNumber = Math.max(Math.min(Math.ceil(totalCount / resultPerPage), this.getMaxNumberOfPagesForCurrentResultsPerPage()), 1);
const halfLength = Math.floor(this.options.numberOfPages / 2);
const firstPageNumber = Math.max(Math.min(currentPage - halfLength, lastPageNumber - this.options.numberOfPages + 1), 1);
const endPageNumber = Math.min(firstPageNumber + this.options.numberOfPages - 1, lastPageNumber);
return {
start: firstPageNumber,
end: endPageNumber,
lastResultPage: lastPageNumber,
currentPage: currentPage
};
}
private renderNavigationButton(pagerBoundary: { start: number; end: number; lastResultPage: number; currentPage: number }) {
if (this.currentPage > 1) {
const previous = this.renderPreviousButton();
this.list.insertBefore(previous.el, this.list.firstChild);
}
if (this.currentPage < pagerBoundary.lastResultPage) {
const next = this.renderNextButton();
this.list.appendChild(next.el);
}
}
private renderPreviousButton() {
const previousListItem = $$('li', {
className: 'coveo-pager-previous coveo-pager-anchor coveo-pager-list-item'
});
const previousButton = $$('span', {
title: l('Previous'),
tabindex: 0
});
const previousIcon = $$(
'span',
{
className: 'coveo-pager-previous-icon'
},
SVGIcons.icons.pagerLeftArrow
);
SVGDom.addClassToSVGInContainer(previousIcon.el, 'coveo-pager-previous-icon-svg');
previousButton.append(previousIcon.el);
previousListItem.append(previousButton.el);
new AccessibleButton()
.withElement(previousButton)
.withLabel(l('Previous'))
.withSelectAction(() => this.handleClickPrevious())
.build();
return previousListItem;
}
private renderNextButton() {
const nextListItem = $$('li', {
className: 'coveo-pager-next coveo-pager-anchor coveo-pager-list-item'
});
const nextButton = $$('span', {
title: l('Next'),
tabindex: 0
});
const nextIcon = $$(
'span',
{
className: 'coveo-pager-next-icon'
},
SVGIcons.icons.pagerRightArrow
);
SVGDom.addClassToSVGInContainer(nextIcon.el, 'coveo-pager-next-icon-svg');
nextButton.append(nextIcon.el);
nextListItem.append(nextButton.el);
new AccessibleButton()
.withElement(nextButton)
.withLabel(l('Next'))
.withSelectAction(() => this.handleClickNext())
.build();
return nextListItem;
}
private handleQueryStateFirstResultChanged(data: IAttributeChangedEventArg) {
if (!this.listenToQueryStateChange) {
return;
}
Assert.exists(data);
this.needToReset = false;
const firstResult = data.value;
this.currentPage = this.fromFirstResultsToPageNumber(firstResult);
}
private handleQueryStateNumberOfResultsPerPageChanged(data: IAttributeChangedEventArg) {
const firstResult = this.queryStateModel.get(QUERY_STATE_ATTRIBUTES.FIRST);
this.searchInterface.resultsPerPage = data.value;
this.currentPage = this.fromFirstResultsToPageNumber(firstResult);
}
private handleClickPage(pageNumber: number) {
Assert.exists(pageNumber);
this.setPage(pageNumber);
}
private handleClickPrevious() {
this.previousPage();
}
private handleClickNext() {
this.nextPage();
}
private fromFirstResultsToPageNumber(firstResult: number): number {
return firstResult / this.searchInterface.resultsPerPage + 1;
}
private getFirstResultNumber(pageNumber: number = this.currentPage): number {
return (pageNumber - 1) * this.searchInterface.resultsPerPage;
}
private getQueryEventArgs() {
return {
count: this.searchInterface.resultsPerPage,
first: this.getFirstResultNumber()
};
}
}
Initialization.registerAutoCreateComponent(Pager);