coveo-search-ui
Version:
Coveo JavaScript Search Framework
404 lines (353 loc) • 15.5 kB
text/typescript
import 'styling/_ResultLayoutSelector';
import { contains, difference, each, filter, find, isEmpty, keys, uniq, pick } from 'underscore';
import { InitializationEvents } from '../../events/InitializationEvents';
import { IQueryErrorEventArgs, IQuerySuccessEventArgs, QueryEvents } from '../../events/QueryEvents';
import { IResultLayoutPopulateArgs, ResultLayoutEvents } from '../../events/ResultLayoutEvents';
import { IChangeLayoutEventArgs, ResultListEvents } from '../../events/ResultListEvents';
import { Assert } from '../../misc/Assert';
import { IAttributesChangedEventArg, MODEL_EVENTS } from '../../models/Model';
import { QueryStateModel, QUERY_STATE_ATTRIBUTES } from '../../models/QueryStateModel';
import { IQueryResults } from '../../rest/QueryResults';
import { exportGlobally } from '../../GlobalExports';
import { l } from '../../strings/Strings';
import { $$ } from '../../utils/Dom';
import { SVGDom } from '../../utils/SVGDom';
import { SVGIcons } from '../../utils/SVGIcons';
import { Utils } from '../../utils/Utils';
import { analyticsActionCauseList, IAnalyticsResultsLayoutChange } from '../Analytics/AnalyticsActionListMeta';
import { Component } from '../Base/Component';
import { IComponentBindings } from '../Base/ComponentBindings';
import { ComponentOptions } from '../Base/ComponentOptions';
import { Initialization } from '../Base/Initialization';
import { ResponsiveResultLayout } from '../ResponsiveComponents/ResponsiveResultLayout';
import { ValidLayout } from './ValidLayout';
import ResultListModule = require('../ResultList/ResultList');
import { AccessibleButton } from '../../utils/AccessibleButton';
export interface IActiveLayouts {
button: {
el: HTMLElement;
visible: boolean;
};
enabled: boolean;
}
export interface IResultLayoutOptions {
mobileLayouts: string[];
tabletLayouts: string[];
desktopLayouts: string[];
}
export const defaultLayout: ValidLayout = 'list';
/**
* The ResultLayoutSelector component allows the end user to switch between multiple {@link ResultList} components that have
* different {@link ResultList.options.layout} values.
*
* This component automatically populates itself with buttons to switch between the ResultList components that have a
* valid layout value (see the {@link ValidLayout} type).
*
* See also the [Result Layouts](https://docs.coveo.com/en/360/) documentation.
*
* @availablesince [February 2018 Release (v2.3826.10)](https://docs.coveo.com/en/410/#february-2018-release-v2382610)
*/
export class ResultLayoutSelector extends Component {
static ID = 'ResultLayoutSelector';
static aliases = ['ResultLayout'];
static doExport = () => {
exportGlobally({
ResultLayoutSelector: ResultLayoutSelector,
ResultLayout: ResultLayoutSelector
});
};
public static validLayouts: ValidLayout[] = ['list', 'card', 'table'];
public currentLayout: ValidLayout;
private preferredLayout: ValidLayout = null;
private currentActiveLayouts: { [key: string]: IActiveLayouts };
private resultLayoutSection: HTMLElement;
private hasNoResults: boolean;
/**
* The component options
* @componentOptions
*/
static options: IResultLayoutOptions = {
/**
* Specifies the layouts that should be available when the search page is displayed in mobile mode.
*
* By default, the mobile mode breakpoint is at 480 px screen width.
*
* To change this default value, use the [responsiveSmallBreakpoint]{@link SearchInterface.options.responsiveSmallBreakpoint} option.
*
* When the breakpoint is reached, layouts that are not specified becomes inactive and the linked result list will be disabled.
*
* The possible values for layouts are `list`, `card`, `table`.
*
* The default value is `card`, `table`.
*/
mobileLayouts: ComponentOptions.buildListOption<ValidLayout>({ defaultValue: ['card', 'table'] }),
/**
* Specifies the layouts that should be available when the search page is displayed in tablet mode.
*
* By default, the tablet mode breakpoint is at 800 px screen width.
*
* To change this default value, use the [responsiveMediumBreakpoint]{@link SearchInterface.options.responsiveMediumBreakpoint} option.
*
* When the breakpoint is reached, layouts that are not specified becomes inactive and the linked result list will be disabled.
*
* The possible values for layouts are `list`, `card`, `table`.
*
* The default value is `list`, `card`, `table`.
*/
tabletLayouts: ComponentOptions.buildListOption<ValidLayout>({ defaultValue: ['list', 'card', 'table'] }),
/**
* Specifies the layouts that should be available when the search page is displayed in desktop mode.
*
* By default, the desktop mode breakpoint is any screen size over 800 px.
*
* To change this default value, use the [responsiveMediumBreakpoint]{@link SearchInterface.options.responsiveMediumBreakpoint} option.
*
* When the breakpoint is reached, layouts that are not specified becomes inactive and the linked result list will be disabled.
*
* The possible values for layouts are `list`, `card`, `table`.
*
* The default value is `list`, `card`, `table`.
*/
desktopLayouts: ComponentOptions.buildListOption<ValidLayout>({ defaultValue: ['list', 'card', 'table'] })
};
/**
* Creates a new ResultLayoutSelector component.
* @param element The HTMLElement on which to instantiate the component.
* @param options The options for the ResultLayout 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?: IResultLayoutOptions, bindings?: IComponentBindings) {
super(element, ResultLayoutSelector.ID, bindings);
this.options = ComponentOptions.initComponentOptions(element, ResultLayoutSelector, options);
this.currentActiveLayouts = {};
this.bind.onQueryState(MODEL_EVENTS.CHANGE_ONE, QUERY_STATE_ATTRIBUTES.LAYOUT, this.handleQueryStateChanged.bind(this));
this.bind.onRootElement(QueryEvents.querySuccess, (args: IQuerySuccessEventArgs) => this.handleQuerySuccess(args));
this.bind.onRootElement(QueryEvents.queryError, (args: IQueryErrorEventArgs) => this.handleQueryError(args));
this.resultLayoutSection = $$(this.element).closest('.coveo-result-layout-section');
this.bind.oneRootElement(InitializationEvents.afterComponentsInitialization, () => this.populate());
this.bind.oneRootElement(InitializationEvents.afterInitialization, () => this.handleQueryStateChanged());
ResponsiveResultLayout.init(this.root, this, {});
}
public get activeLayouts(): { [key: string]: IActiveLayouts } {
if (this.searchInterface.responsiveComponents.isLargeScreenWidth()) {
return pick(this.currentActiveLayouts, this.options.desktopLayouts);
}
if (this.searchInterface.responsiveComponents.isMediumScreenWidth()) {
return pick(this.currentActiveLayouts, this.options.tabletLayouts);
}
if (this.searchInterface.responsiveComponents.isSmallScreenWidth()) {
return pick(this.currentActiveLayouts, this.options.mobileLayouts);
}
return this.currentActiveLayouts;
}
/**
* Changes the current layout.
*
* Also logs a `resultLayoutChange` event in the usage analytics with the new layout as metadeta.
*
* Triggers a new query.
*
* @param layout The new layout. The page must contain a valid {@link ResultList} component with a matching
* {@link ResultList.options.layout} value for this method to work.
*/
public changeLayout(layout: ValidLayout) {
this.preferredLayout = null;
this.performLayoutChange(layout);
}
/**
* Gets the current layout (`list`, `card` or `table`).
* @returns {string} The current current layout.
*/
public getCurrentLayout() {
return this.currentLayout;
}
public disableLayouts(layouts: ValidLayout[]) {
if (Utils.isNonEmptyArray(layouts)) {
each(layouts, layout => this.disableLayout(layout));
let remainingValidLayouts = difference(keys(this.currentActiveLayouts), layouts);
this.preferredLayout = this.currentLayout;
if (!isEmpty(remainingValidLayouts)) {
const newLayout = contains(remainingValidLayouts, this.currentLayout) ? this.currentLayout : remainingValidLayouts[0];
this.performLayoutChange(<ValidLayout>newLayout);
} else {
this.logger.error('Cannot disable the last valid layout ... Re-enabling the first one possible');
let firstPossibleValidLayout = <ValidLayout>keys(this.currentActiveLayouts)[0];
this.enableLayout(firstPossibleValidLayout);
this.setLayout(firstPossibleValidLayout);
}
}
}
public enableLayouts(layouts: ValidLayout[]) {
each(layouts, layout => this.enableLayout(layout));
const preferredLayoutAvailable = find(layouts, layout => layout === this.preferredLayout);
preferredLayoutAvailable && this.restorePreferredLayout();
}
private restorePreferredLayout() {
this.performLayoutChange(this.preferredLayout);
this.preferredLayout = null;
}
private performLayoutChange(layout: ValidLayout) {
Assert.check(this.isLayoutDisplayedByButton(layout), 'Layout not available or invalid');
if (layout !== this.currentLayout || this.getModelValue() === '') {
this.setModelValue(layout);
const lastResults = this.queryController.getLastResults();
this.setLayout(layout, lastResults);
if (lastResults) {
this.usageAnalytics.logCustomEvent<IAnalyticsResultsLayoutChange>(
analyticsActionCauseList.resultsLayoutChange,
{
resultsLayoutChangeTo: layout
},
this.element
);
} else {
this.usageAnalytics.logSearchEvent<IAnalyticsResultsLayoutChange>(analyticsActionCauseList.resultsLayoutChange, {
resultsLayoutChangeTo: layout
});
if (!this.queryController.firstQuery) {
this.queryController.executeQuery();
}
}
}
}
private disableLayout(layout: ValidLayout) {
if (this.isLayoutDisplayedByButton(layout)) {
this.hideButton(layout);
}
}
private enableLayout(layout: ValidLayout) {
const allResultLists = this.resultLists;
const atLeastOneResultListCanShowLayout = find(allResultLists, resultList => resultList.options.layout == layout);
if (atLeastOneResultListCanShowLayout && this.isLayoutDisplayedByButton(layout)) {
this.showButton(layout);
this.updateSelectorAppearance();
}
}
private get resultLists(): ResultListModule.ResultList[] {
return this.searchInterface.getComponents('ResultList');
}
private hideButton(layout: ValidLayout) {
if (this.isLayoutDisplayedByButton(layout)) {
let btn = this.currentActiveLayouts[<string>layout].button;
$$(btn.el).addClass('coveo-hidden');
btn.visible = false;
this.updateSelectorAppearance();
}
}
private showButton(layout: ValidLayout) {
if (this.isLayoutDisplayedByButton(layout)) {
let btn = this.currentActiveLayouts[<string>layout].button;
$$(btn.el).removeClass('coveo-hidden');
btn.visible = true;
}
}
private setLayout(layout: ValidLayout, results?: IQueryResults) {
if (layout) {
if (this.currentLayout) {
$$(this.currentActiveLayouts[this.currentLayout].button.el).removeClass('coveo-selected');
$$(this.currentActiveLayouts[this.currentLayout].button.el).setAttribute('aria-pressed', false.toString());
}
$$(this.currentActiveLayouts[layout].button.el).addClass('coveo-selected');
$$(this.currentActiveLayouts[layout].button.el).setAttribute('aria-pressed', true.toString());
this.currentLayout = layout;
$$(this.element).trigger(ResultListEvents.changeLayout, <IChangeLayoutEventArgs>{
layout: layout,
results: results
});
}
}
private handleQuerySuccess(args: IQuerySuccessEventArgs) {
this.hasNoResults = args.results.results.length == 0;
if (this.shouldShowSelector()) {
this.show();
} else {
this.hide();
}
}
private handleQueryStateChanged(args?: IAttributesChangedEventArg) {
const modelLayout = this.getModelValue();
const newLayout = find(keys(this.currentActiveLayouts), l => l === modelLayout);
if (newLayout !== undefined) {
this.setLayout(<ValidLayout>newLayout);
} else {
this.setLayout(<ValidLayout>keys(this.currentActiveLayouts)[0]);
}
}
private handleQueryError(args: IQueryErrorEventArgs) {
this.hasNoResults = true;
this.hide();
}
private updateSelectorAppearance() {
if (this.shouldShowSelector()) {
this.show();
} else {
this.hide();
}
}
private populate() {
let populateArgs: IResultLayoutPopulateArgs = { layouts: [] };
$$(this.root).trigger(ResultLayoutEvents.populateResultLayout, populateArgs);
const layouts = uniq(populateArgs.layouts.map(layout => layout.toLowerCase()));
each(layouts, layout => Assert.check(contains(ResultLayoutSelector.validLayouts, layout), 'Invalid layout'));
if (!isEmpty(layouts)) {
each(layouts, layout => this.addButton(layout));
if (!this.shouldShowSelector()) {
this.hide();
}
}
}
private addButton(layout: string) {
const btn = $$('span', {
className: 'coveo-result-layout-selector'
});
const caption = $$('span', { className: 'coveo-result-layout-selector-caption' }, l(layout));
btn.append(caption.el);
const icon = $$('span', { className: `coveo-icon coveo-${layout}-layout-icon` }, SVGIcons.icons[`${layout}Layout`]);
SVGDom.addClassToSVGInContainer(icon.el, `coveo-${layout}-svg`);
btn.prepend(icon.el);
const selectAction = () => this.changeLayout(<ValidLayout>layout);
new AccessibleButton()
.withElement(btn)
.withLabel(l('DisplayResultsAs', l(layout)))
.withSelectAction(selectAction)
.withOwner(this.bind)
.build();
const isCurrentLayout = layout === this.currentLayout;
btn.toggleClass('coveo-selected', isCurrentLayout);
btn.setAttribute('aria-pressed', isCurrentLayout.toString());
$$(this.element).append(btn.el);
this.currentActiveLayouts[layout] = {
button: {
visible: true,
el: btn.el
},
enabled: true
};
}
private hide() {
const elem = this.resultLayoutSection || this.element;
$$(elem).addClass('coveo-result-layout-hidden');
}
private show() {
const elem = this.resultLayoutSection || this.element;
$$(elem).removeClass('coveo-result-layout-hidden');
}
private getModelValue(): string {
return this.queryStateModel.get(QueryStateModel.attributesEnum.layout);
}
private setModelValue(val: string) {
this.queryStateModel.set(QueryStateModel.attributesEnum.layout, val);
}
private shouldShowSelector() {
return (
keys(this.currentActiveLayouts).length > 1 &&
filter(this.currentActiveLayouts, (activeLayout: IActiveLayouts) => activeLayout.button.visible).length > 1 &&
!this.hasNoResults
);
}
private isLayoutDisplayedByButton(layout: ValidLayout) {
return contains(keys(this.currentActiveLayouts), layout);
}
}
Initialization.registerAutoCreateComponent(ResultLayoutSelector);