coveo-search-ui
Version:
Coveo JavaScript Search Framework
1,061 lines (958 loc) • 40.9 kB
text/typescript
/// <reference path="../Facet/FacetHeader.ts" />
/// <reference path="../../controllers/FacetSliderQueryController.ts" />
import 'styling/_FacetSlider';
import { debounce, every, map } from 'underscore';
import { FacetSliderQueryController } from '../../controllers/FacetSliderQueryController';
import { BreadcrumbEvents, IBreadcrumbItem, IPopulateBreadcrumbEventArgs } from '../../events/BreadcrumbEvents';
import { InitializationEvents } from '../../events/InitializationEvents';
import { IBuildingQueryEventArgs, IDoneBuildingQueryEventArgs, IQuerySuccessEventArgs, QueryEvents } from '../../events/QueryEvents';
import { ISearchAlertsPopulateMessageEventArgs, SearchAlertsEvents } from '../../events/SearchAlertEvents';
import { IGraphValueSelectedArgs, SliderEvents } from '../../events/SliderEvents';
import { exportGlobally } from '../../GlobalExports';
import { Assert } from '../../misc/Assert';
import { Defer } from '../../MiscModules';
import { IAttributeChangedEventArg, Model } from '../../models/Model';
import { QueryStateModel } from '../../models/QueryStateModel';
import { IGroupByResult } from '../../rest/GroupByResult';
import { l } from '../../strings/Strings';
import { getHeadingTag } from '../../utils/AccessibilityUtils';
import { $$ } from '../../utils/Dom';
import { SVGDom } from '../../utils/SVGDom';
import { SVGIcons } from '../../utils/SVGIcons';
import { Utils } from '../../utils/Utils';
import {
analyticsActionCauseList,
IAnalyticsFacetGraphSelectedMeta,
IAnalyticsFacetMeta,
IAnalyticsFacetSliderChangeMeta
} from '../Analytics/AnalyticsActionListMeta';
import { Component } from '../Base/Component';
import { IComponentBindings } from '../Base/ComponentBindings';
import { ComponentOptions } from '../Base/ComponentOptions';
import { IComponentOptionsObjectOptionArgs, IFieldOption, IQueryExpression } from '../Base/IComponentOptions';
import { Initialization } from '../Base/Initialization';
import { FacetHeader } from '../Facet/FacetHeader';
import { IDuringSlideEventArgs, IEndSlideEventArgs, ISliderGraphData, ISliderOptions, Slider } from '../Misc/Slider';
import { IResponsiveComponentOptions } from '../ResponsiveComponents/ResponsiveComponentsManager';
import { ResponsiveComponentsUtils } from '../ResponsiveComponents/ResponsiveComponentsUtils';
import { ResponsiveDropdownEvent } from '../ResponsiveComponents/ResponsiveDropdown/ResponsiveDropdown';
import { ResponsiveFacetOptions } from '../ResponsiveComponents/ResponsiveFacetOptions';
import { ResponsiveFacetSlider } from '../ResponsiveComponents/ResponsiveFacetSlider';
export interface IFacetSliderOptions extends ISliderOptions, IResponsiveComponentOptions {
dateField?: boolean;
queryOverride?: IQueryExpression;
id?: string;
field?: IFieldOption;
title?: string;
}
/**
* The `FacetSlider` component creates a facet which contains a slider widget that allows the end user to filter results
* based on a range of numerical values (e.g., a date range, a price range, etc.).
*
* **Note:**
* > This component does **not** inherit from the [`Facet`]{@link Facet} component. Consequently, it does not offer the
* > same configuration options. Moreover, some of the `FacetSlider` options (see
* > [`getSteps`]{@link FacetSlider.options.getSteps} and [`valueCaption`]{@link FacetSlider.options.valueCaption})
* > cannot be configured as `data-` attributes in the markup. If you wish to configure those options, you must either
* > do so in the [`init`]{@link init} call of your search interface (see
* > [Passing Component Options in the init Call](https://docs.coveo.com/en/346/#passing-component-options-in-the-init-call)),
* > or before the `init` call, using the `options` top-level function (see
* > [Passing Component Options Before the init Call](https://docs.coveo.com/en/346/#passing-component-options-before-the-init-call)).
* @notSupportedIn salesforcefree
*/
export class FacetSlider extends Component {
/**
* The component options
* @componentOptions
*/
static options: IFacetSliderOptions = {
/**
* Specifies the title to display on top of the `FacetSlider`.
*
* Default value is the localized string for `NoTitle`.
*/
title: ComponentOptions.buildLocalizedStringOption({
localizedString: () => l('NoTitle'),
section: 'CommonOptions'
}),
/**
* Specifies whether the [`field`]{@link FacetSlider.options.field} for which you are requesting a range is a date
* field. This allows the `FacetSlider` to correctly build the outgoing [GroupByRequest]{@link IGroupByRequest} and
* render itself properly.
*
* Default value is `false`.
*/
dateField: ComponentOptions.buildBooleanOption({ defaultValue: false, section: 'CommonOptions' }),
/**
* Specifies the index field whose values the `FacetSlider` should use.
*
* The field must be configured correctly as a Facet field in the index (see
* [Adding and Managing Fields](https://docs.coveo.com/en/1833/)).
*
* Specifying a value for this option is required for the `FacetSlider` component to work.
*/
field: ComponentOptions.buildFieldOption({ groupByField: true, required: true, section: 'CommonOptions' }),
/**
* Specifies a unique identifier for the `FacetSlider`. Among other things, this identifier serves the purpose of
* saving the facet state in the URL hash.
*
* If you have two facets with the same field in the same page, you should specify a unique `id` value for at least
* one of those two facets. This `id` must be unique in the page.
*
* Default value is the [`field`]{@link FacetSlider.options.field} option value.
*/
id: ComponentOptions.buildStringOption({
postProcessing: (value, options: IFacetSliderOptions) => value || <string>options.field
}),
/**
* Specifies the format to use when displaying date values.
*
* See also the [`dateField`]{@link FacetSlider.options.dateField} option.
*
* Default value is `MMM dd, yyyy`.
*/
dateFormat: ComponentOptions.buildStringOption({ section: 'Display' }),
/**
* Specifies a query to filter automatic minimum and maximum values for the slider range.
*
* This is especially useful in the case of date ranges since the index may contain values which are not set, and
* thus return values from the year 1400 (the earliest date from the boost C++ library).
*
* **Example:**
*
* The query override in the following markup filters out any `@date` value anterior to January 1st 2000.
* ```html
* <div class="CoveoFacetSlider" data-field="@date" data-date-field="true" data-query-override="@date>2000/01/01"></div>
* ```
*/
queryOverride: ComponentOptions.buildQueryExpressionOption({ section: 'Filtering' }),
/**
* Specifies the starting boundary of the slider.
*
* Date values are rounded to the nearest year when you set the [`dateField`]{@link FacetSlider.options.dateField}
* option to `true`.
*
* Default value is the lowest available field value in the index.
*/
start: ComponentOptions.buildStringOption({ section: 'Filtering' }),
/**
* Specifies the ending boundary of the slider.
*
* Date values are rounded to the nearest year when you set the [`dateField`]{@link FacetSlider.options.dateField}
* option to `true`.
*
* Default value is the highest available field value in the index.
*/
end: ComponentOptions.buildStringOption({ section: 'Filtering' }),
/**
* Specifies whether to exclude the outer bounds of the slider in the generated query when they are not active.
*
* Default value is `false`.
*/
excludeOuterBounds: ComponentOptions.buildBooleanOption({ defaultValue: false, section: 'Filtering' }),
/**
* Specifies the number of decimal places to round the displayed numerical values to.
*
* Default (and minimum) value is `0`.
*/
rounded: ComponentOptions.buildNumberOption({ min: 0, section: 'Filtering' }),
/**
* Specifies the number of steps to split the slider into.
*
* For example, if your range is [ 0 , 100 ] and you specify `10` steps, then the end user can move the slider only
* to the values [ 0, 10, 20, 30 ... , 100 ].
*
* For performance reasons, the maximum value for this option is `100`
*
* Default value is `undefined`, and the slider allows up to 100 steps. Minimum value is `2`.
*/
steps: ComponentOptions.buildNumberOption({ min: 2 }),
/**
* Specifies whether you want a slider with two buttons instead of a slider with a single button.
*
* By default, only one button appears in the slider.
*/
rangeSlider: ComponentOptions.buildBooleanOption(),
/**
* Specifies the caption options to use when displaying the field values.
*
* Available options are:
* - enable (`data-display-as-value-enable`): boolean; specifies whether to display the caption as a value. Default
* value is `true`.
* - unitSign (`data-display-as-value-unit-sign`): string; specifies the unit sign for this value (e.g., `$`).
* Default value is `undefined`.
* - separator (`data-display-as-value-separator`): string; specifies the character(s) to use as a separator in the
* caption. Default value is `"-"`.
*/
displayAsValue: ComponentOptions.buildObjectOption(<IComponentOptionsObjectOptionArgs>{
subOptions: {
enable: ComponentOptions.buildBooleanOption({ defaultValue: true }),
unitSign: ComponentOptions.buildStringOption(),
separator: ComponentOptions.buildStringOption({ defaultValue: '-' })
},
section: 'Display'
}),
/**
* Specifies the percentage caption options to use when displaying the field values.
*
* Available options are:
* - enable (`data-display-as-percent-enable`): boolean; specifies whether to display the caption as a percentage.
* Default value is `false`.
* separator (`data-display-as-percent-separator`): string; specifies the character(s) to use as a separator in the
* caption. Default value is `"-"`.
*/
displayAsPercent: ComponentOptions.buildObjectOption(<IComponentOptionsObjectOptionArgs>{
subOptions: {
enable: ComponentOptions.buildBooleanOption({ defaultValue: false }),
separator: ComponentOptions.buildStringOption({ defaultValue: '-' })
},
section: 'Display'
}),
/**
* Specifies whether to display a small graph on top of the slider.
*
* Available options are:
* - steps (`data-graph-steps`): number; specifies the number of steps/columns to display in your graph. Default
* value is `10`. Minimum value is `2`.
*/
graph: ComponentOptions.buildObjectOption(<IComponentOptionsObjectOptionArgs>{
subOptions: {
steps: ComponentOptions.buildNumberOption({ min: 2 }),
animationDuration: ComponentOptions.buildNumberOption({ min: 0 }),
margin: ComponentOptions.buildObjectOption(<IComponentOptionsObjectOptionArgs>{
subOptions: {
top: ComponentOptions.buildNumberOption({ min: 0 }),
bottom: ComponentOptions.buildNumberOption({ min: 0 }),
left: ComponentOptions.buildNumberOption({ min: 0 }),
right: ComponentOptions.buildNumberOption({ min: 0 })
}
})
},
section: 'Graph'
}),
/**
* Specifies a function to generate the `FacetSlider` steps (see the [`steps`]{@link FacetSlider.options.steps}
* option). This function receives the `FacetSlider` boundaries (see the [`start`]{@link FacetSlider.options.start}
* and [`end`]{@link FacetSlider.options.end} options), and must return an array of numbers (the steps).
*
* **Note:**
* > You cannot set this option directly in the component markup as an HTML attribute. You must either set it in the
* > [`init`]{@link init} call of your search interface (see
* > [Passing Component Options in the init Call](https://docs.coveo.com/en/346/#passing-component-options-in-the-init-call)),
* > or before the `init` call, using the `options` top-level function (see
* > [Passing Component Options Before the init Call](https://docs.coveo.com/en/346/#passing-component-options-before-the-init-call)).
*
* **Example:**
* ```javascript
*
* var myGetStepsFunction = function(start, end) {
* var result = [];
* for (i = start; i < end; i += 2) {
* result.push(i);
* }
* return result;
* }
*
* // You can set the option in the 'init' call:
* Coveo.init(document.querySelector("#search"), {
* FacetSlider: {
* getSteps: myGetStepsFunction
* }
* });
*
* // Or before the 'init' call, using the 'options' top-level function:
* // Coveo.options(document.querySelector("#search"), {
* // FacetSlider : {
* // valueCaption : myGetStepsFunction
* // }
* // });
* ```
*/
getSteps: ComponentOptions.buildCustomOption<(start: number, end: number) => number[]>(() => {
return null;
}),
/**
* Specifies a function to generate the value caption for the `FacetSlider`. This function receives the current
* slider values (number[]), and must return the caption (string).
*
* **Note:**
* > You cannot set this option directly in the component markup as an HTML attribute. You must either set it in the
* > [`init`]{@link init} call of your search interface (see
* > [Passing Component Options in the init Call](https://docs.coveo.com/en/346/#passing-component-options-in-the-init-call)),
* > or before the `init` call, using the `options` top-level function (see
* > [Passing Component Options Before the init Call](https://docs.coveo.com/en/346/#passing-component-options-before-the-init-call)).
*
* **Example:**
* ```javascript
*
* var myValueCaptionFunction = function(values) {
* return "From " + values[0] + " to " + values[1];
* }
*
* // You can set the option in the 'init' call:
* Coveo.init(document.querySelector("#search"), {
* FacetSlider: {
* valueCaption: myValueCaptionFunction
* }
* });
*
* // Or before the 'init' call, using the 'options' top-level function:
* // Coveo.options(document.querySelector("#search"), {
* // FacetSlider : {
* // valueCaption : myValueCaptionFunction
* // }
* // });
* ```
*/
valueCaption: ComponentOptions.buildCustomOption<(values: number[]) => string>(() => {
return null;
}),
...ResponsiveFacetOptions
};
static ID = 'FacetSlider';
static doExport = () => {
exportGlobally({
FacetSlider: FacetSlider,
Slider: Slider
});
};
public static DEBOUNCED_RESIZE_DELAY = 250;
public startOfSlider: number;
public endOfSlider: number;
public initialStartOfSlider: number;
public initialEndOfSlider: number;
public facetQueryController: FacetSliderQueryController;
public facetHeader: FacetHeader;
public onResize: EventListener;
public isSimpleSliderConfig = false;
public isFieldValueCompatible = false;
private rangeQueryStateAttribute: string;
private isEmpty = false;
private rangeFromUrlState: number[];
private delayedGraphData: ISliderGraphData[];
/**
* Creates a new `FacetSlider` component. Binds multiple query events as well.
* @param element The HTMLElement on which to instantiate the component.
* @param options The options for the `FacetSlider` 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 slider
*/
constructor(public element: HTMLElement, public options: IFacetSliderOptions, bindings?: IComponentBindings, private slider?: Slider) {
super(element, FacetSlider.ID, bindings);
this.options = ComponentOptions.initComponentOptions(element, FacetSlider, options);
this.isSimpleSliderConfig = this.options.start != null && this.options.end != null;
ResponsiveFacetSlider.init(this.root, this, this.options);
if (this.options.excludeOuterBounds == null) {
this.options.excludeOuterBounds = false;
}
this.normalizeStartAndEndOptionsValues();
this.facetQueryController = new FacetSliderQueryController(this);
this.bindQueryStateEvents();
this.bindQueryEvents();
this.bindResizeEvents();
this.bindBreadcrumbEvents();
this.bind.onRootElement(SearchAlertsEvents.searchAlertsPopulateMessage, (args: ISearchAlertsPopulateMessageEventArgs) =>
this.handlePopulateSearchAlerts(args)
);
}
public isCurrentlyDisplayed() {
if (!$$(this.element).isVisible()) {
return false;
}
if ($$(this.element).hasClass('coveo-disabled-empty')) {
return false;
}
return true;
}
public createDom() {
this.facetHeader = new FacetHeader({
field: <string>this.options.field,
facetElement: this.element,
title: this.options.title,
enableClearElement: true,
enableCollapseElement: true,
facetSlider: this
});
this.element.appendChild(this.facetHeader.build());
}
/**
* Resets the `FacetSlider` (meaning that you need to set the range value as inactive).
*/
public reset() {
if (this.slider) {
this.slider.initializeState();
this.updateQueryState();
this.updateAppearanceDependingOnState();
}
}
/**
* Gets the current selection in the slider.
*
* **Note:**
* > This method returns an array of number for selected date values. These numbers represent a number of milliseconds
* > before or after January 1, 1970. Therefore, you can use these numbers to instantiate standard JavaScript Date
* > objects.
*
* @returns {any} An array of number containing the first and last selected values, if possible. An array containing
* two `undefined` values otherwise.
*/
public getSelectedValues(): number[] {
if (this.startOfSlider != undefined && this.endOfSlider != undefined) {
return [this.startOfSlider, this.endOfSlider];
} else {
return [undefined, undefined];
}
}
/**
* Sets the selected values in the slider.
*
* **Note:**
* > You must set date values with numbers representing a number of milliseconds before or after January 1, 1970. You
* > can easily extract such numbers from standard JavaScript Date objects.
*
* @param values [start, end] An array containing the first and last values to select in the slider.
*/
public setSelectedValues(values: number[]): void {
this.setupSliderIfNeeded(undefined);
this.startOfSlider = values[0];
this.endOfSlider = values[1];
this.slider.setValues([this.startOfSlider, this.endOfSlider]);
this.updateAppearanceDependingOnState();
}
/**
* Indicates whether the `FacetSlider` is active. An active `FacetSlider` outputs an expression in the query when a
* search is performed.
* @returns {boolean} `true` if the FacetSlider is active; `false` otherwise.
*/
public isActive(): boolean {
return (
!isNaN(this.startOfSlider) &&
!isNaN(this.endOfSlider) &&
!isNaN(this.initialStartOfSlider) &&
!isNaN(this.initialEndOfSlider) &&
(this.startOfSlider != this.initialStartOfSlider || this.endOfSlider != this.initialEndOfSlider)
);
}
public getSliderBoundaryForQuery(): number[] {
let needToReturnABoundary = false;
if (!this.slider) {
needToReturnABoundary = true;
} else if (this.slider && this.isActive()) {
needToReturnABoundary = true;
}
if (needToReturnABoundary) {
return this.generateBoundary();
} else {
return undefined;
}
}
// There is delayed graph data if at the time the facet slider tried to draw, the facet was hidden in the
// facet dropdown. This method will draw delayed graph data if it exists.
public drawDelayedGraphData() {
if (this.delayedGraphData != null && !this.isEmpty) {
this.slider.drawGraph(this.delayedGraphData);
}
this.delayedGraphData = null;
}
public hasAGraph() {
return this.options.graph != undefined;
}
private normalizeStartAndEndOptionsValues() {
if (this.options.start) {
this.options.start = this.options.dateField
? <any>new Date(this.options.start.replace(/-/g, '/')).getTime()
: <any>Number(this.options.start);
}
if (this.options.end) {
this.options.end = this.options.dateField
? <any>new Date(this.options.end.replace(/-/g, '/')).getTime()
: <any>Number(this.options.end);
}
}
private bindQueryEvents() {
this.bind.onRootElement(QueryEvents.newQuery, () => this.handleNewQuery());
this.bind.onRootElement(QueryEvents.noResults, () => this.handleNoresults());
this.bind.onRootElement(QueryEvents.deferredQuerySuccess, (args: IQuerySuccessEventArgs) => this.handleDeferredQuerySuccess(args));
this.bind.onRootElement(QueryEvents.buildingQuery, (args: IBuildingQueryEventArgs) => this.handleBuildingQuery(args));
this.bind.onRootElement(QueryEvents.doneBuildingQuery, (args: IDoneBuildingQueryEventArgs) => this.handleDoneBuildingQuery(args));
}
private bindResizeEvents() {
this.onResize = debounce(() => {
if (ResponsiveComponentsUtils.shouldDrawFacetSlider($$(this.root), $$(this.element)) && this.slider && !this.isEmpty) {
if (this.delayedGraphData) {
this.drawDelayedGraphData();
} else {
this.slider.drawGraph();
}
}
if (this.slider) {
this.slider.onMoving();
}
}, 250);
window.addEventListener('resize', this.onResize);
this.bind.onRootElement(ResponsiveDropdownEvent.OPEN, this.onResize);
// This is used inside SF integration
this.bind.onRootElement('onPopupOpen', this.onResize);
$$(this.root).on(InitializationEvents.nuke, () => this.handleNuke());
}
private bindBreadcrumbEvents() {
this.bind.onRootElement(BreadcrumbEvents.clearBreadcrumb, () => this.reset());
this.bind.onRootElement(BreadcrumbEvents.populateBreadcrumb, (args: IPopulateBreadcrumbEventArgs) =>
this.handlePopulateBreadcrumb(args)
);
}
private handleNoresults(): void {
this.isEmpty = true;
this.updateAppearanceDependingOnState();
}
private handleNewQuery(): void {
this.isEmpty = false;
}
private handleRangeQueryStateChanged(args: IAttributeChangedEventArg): void {
this.setupSliderIfNeeded(args);
this.startOfSlider = args.value[0] == undefined ? this.startOfSlider : args.value[0];
this.endOfSlider = args.value[1] == undefined ? this.endOfSlider : args.value[1];
this.setSelectedValues([this.startOfSlider, this.endOfSlider]);
}
private handlePopulateBreadcrumb(args: IPopulateBreadcrumbEventArgs): void {
const populateBreadcrumb = () => {
if (this.isActive()) {
args.breadcrumbs.push(<IBreadcrumbItem>{
element: this.buildBreadcrumbFacetSlider(args.headingLevel)
});
}
};
if (this.slider) {
populateBreadcrumb();
} else {
$$(this.root).one(QueryEvents.deferredQuerySuccess, () => {
populateBreadcrumb();
$$(this.root).trigger(BreadcrumbEvents.redrawBreadcrumb);
});
}
}
private handlePopulateSearchAlerts(args: ISearchAlertsPopulateMessageEventArgs) {
if (this.isActive()) {
args.text.push($$(this.buildBreadcrumbFacetSlider()).text());
}
}
private buildBreadcrumbFacetSlider(headingLevel?: number): HTMLElement {
const elem = $$('div', {
className: 'coveo-facet-slider-breadcrumb'
}).el;
const title = $$(getHeadingTag(headingLevel, 'span'), {
className: 'coveo-facet-slider-breadcrumb-title'
});
title.text(this.options.title + ': ');
elem.appendChild(title.el);
const values = $$('span', {
className: 'coveo-facet-slider-breadcrumb-values'
});
elem.appendChild(values.el);
const value = $$('span', {
className: 'coveo-facet-slider-breadcrumb-value'
});
const caption = $$('span', {
className: 'coveo-facet-slider-breadcrumb-caption'
});
caption.text(this.slider.getCaption());
value.append(caption.el);
values.el.appendChild(value.el);
const clear = $$(
'span',
{
className: 'coveo-facet-slider-breadcrumb-clear'
},
SVGIcons.icons.mainClear
);
SVGDom.addClassToSVGInContainer(clear.el, 'coveo-facet-slider-clear-svg');
value.el.appendChild(clear.el);
value.on('click', () => {
this.reset();
this.usageAnalytics.logSearchEvent<IAnalyticsFacetMeta>(analyticsActionCauseList.facetClearAll, {
facetId: this.options.id,
facetField: this.options.field.toString(),
facetTitle: this.options.title
});
this.queryController.executeQuery();
});
return elem;
}
private initSlider() {
this.buildSlider();
this.slider.initializeState([this.startOfSlider, this.endOfSlider]);
this.updateAppearanceDependingOnState();
}
private bindQueryStateEvents() {
this.rangeQueryStateAttribute = QueryStateModel.getFacetId(this.options.id) + ':range';
this.queryStateModel.registerNewAttribute(this.rangeQueryStateAttribute, [undefined, undefined]);
const eventName = this.queryStateModel.getEventName(Model.eventTypes.changeOne + this.rangeQueryStateAttribute);
this.bind.onRootElement(eventName, (args: IAttributeChangedEventArg) => {
this.slider ? this.handleRangeQueryStateChanged(args) : this.setRangeStateSliderStillNotCreated(args);
});
}
private setRangeStateSliderStillNotCreated(args: IAttributeChangedEventArg) {
this.rangeFromUrlState = this.copyValues(args.value);
}
private buildSlider() {
const sliderContainer = $$('div', {
className: 'coveo-slider-container'
}).el;
if (this.hasAGraph()) {
$$(sliderContainer).addClass('coveo-with-graph');
}
const sliderDiv = $$('div').el;
this.slider = this.slider
? this.slider
: new Slider(sliderDiv, { ...this.options, ...{ dateField: this.options.dateField } } as ISliderOptions, this.root);
$$(sliderDiv).on(SliderEvents.endSlide, (e: MouseEvent, args: IEndSlideEventArgs) => {
this.handleEndSlide(args);
});
$$(sliderDiv).on(SliderEvents.duringSlide, (e: MouseEvent, args: IDuringSlideEventArgs) => {
this.handleDuringSlide(args);
});
if (this.hasAGraph()) {
$$(sliderDiv).on(SliderEvents.graphValueSelected, (e: MouseEvent, args: IGraphValueSelectedArgs) => {
this.handleGraphValueSelected(args);
});
}
sliderContainer.appendChild(sliderDiv);
this.element.appendChild(sliderContainer);
this.updateAppearanceDependingOnState();
}
private handleBuildingQuery(data: IBuildingQueryEventArgs) {
Assert.exists(data);
Assert.exists(data.queryBuilder);
const boundary = this.getSliderBoundaryForQuery();
if (boundary != undefined) {
this.facetQueryController.prepareForNewQuery();
const expression = this.facetQueryController.computeOurFilterExpression(boundary);
if (Utils.isNonEmptyString(expression)) {
this.logger.trace('Putting filter in query', expression);
data.queryBuilder.advancedExpression.add(expression);
}
}
}
private handleDoneBuildingQuery(data: IDoneBuildingQueryEventArgs) {
const queryBuilder = data.queryBuilder;
this.facetQueryController.putGroupByIntoQueryBuilder(queryBuilder);
}
private handleDeferredQuerySuccess(data: IQuerySuccessEventArgs) {
this.ensureDom();
this.setupSliderIfNeeded(data);
const groupByResults = data.results.groupByResults[this.facetQueryController.lastGroupByRequestIndex];
this.isEmpty = this.isFacetEmpty(groupByResults, data);
this.updateAppearanceDependingOnState();
if (this.hasAGraph()) {
this.renderToSliderGraph(data);
}
}
private handleEndSlide(args: IEndSlideEventArgs) {
const values = args.slider.getValues();
this.startOfSlider = values[0];
this.endOfSlider = values[1];
if (this.updateQueryState(values)) {
this.updateAppearanceDependingOnState();
this.usageAnalytics.logSearchEvent<IAnalyticsFacetSliderChangeMeta>(analyticsActionCauseList.facetRangeSlider, {
facetId: this.options.id,
facetField: this.options.field.toString(),
facetRangeStart: this.startOfSlider.toString(),
facetRangeEnd: this.endOfSlider.toString()
});
this.queryController.executeQuery();
}
}
private handleDuringSlide(args: IDuringSlideEventArgs) {
const values = args.slider.getValues();
this.startOfSlider = values[0];
this.endOfSlider = values[1];
this.slider.setValues([this.startOfSlider, this.endOfSlider]);
this.updateAppearanceDependingOnState(true);
}
private handleGraphValueSelected(args: IGraphValueSelectedArgs) {
if ((this.options.rangeSlider && this.startOfSlider != args.start) || this.endOfSlider != args.end) {
if (this.options.rangeSlider) {
this.startOfSlider = args.start;
}
this.endOfSlider = args.end;
this.slider.setValues([this.startOfSlider, this.endOfSlider]);
this.updateQueryState();
this.usageAnalytics.logSearchEvent<IAnalyticsFacetGraphSelectedMeta>(analyticsActionCauseList.facetRangeGraph, {
facetId: this.options.id,
facetField: this.options.field.toString(),
facetRangeStart: this.startOfSlider.toString(),
facetRangeEnd: this.endOfSlider.toString()
});
this.queryController.executeQuery();
}
}
private updateQueryState(values = this.slider.getValues(), silent = false) {
const copyOfValues = this.copyValues(values);
const start = values[0] + 0.0;
const end = values[1] + 0.0;
const model: number[] = this.queryStateModel.get(this.rangeQueryStateAttribute);
if (model == null || copyOfValues[0] != model[0] || copyOfValues[1] != model[1]) {
copyOfValues[0] = start;
copyOfValues[1] = end;
this.queryStateModel.set(this.rangeQueryStateAttribute, copyOfValues, { silent: silent });
return true;
}
return false;
}
private copyValues(values: number[]) {
// Creating a copy of the values prevents an unwanted automatic update of the state while sliding
// That's the cleanest way I found to copy that array correctly
const copyOfValues = [];
copyOfValues[0] = Number(values[0]) + 0.0;
copyOfValues[1] = Number(values[1]) + 0.0;
return copyOfValues;
}
private renderToSliderGraph(data: IQuerySuccessEventArgs) {
const rawGroupByResults = data.results.groupByResults[this.facetQueryController.graphGroupByQueriesIndex];
let graphData: ISliderGraphData[];
let totalGraphResults = 0;
if (rawGroupByResults) {
graphData = map(rawGroupByResults.values, value => {
totalGraphResults += value.numberOfResults;
let start: any = value.value.split('..')[0];
let end: any = value.value.split('..')[1];
if (!this.options.dateField) {
start = Number(start);
end = Number(end);
} else {
start = new Date(start.split('@')[0]).getTime();
end = new Date(end.split('@')[0]).getTime();
}
const y = value.numberOfResults;
return {
start: start,
y: y,
end: end,
isDate: this.options.dateField
};
});
}
if (totalGraphResults == 0) {
this.isEmpty = true;
this.updateAppearanceDependingOnState();
}
if (graphData != undefined && !this.isDropdownHidden()) {
// This is deferred since it might be called on initialization with a placehoder over the facet during load time
// We need to wait that the animation is gone so that the width/height calculation done by the graph are okay.
Defer.defer(() => this.slider.drawGraph(graphData));
} else if (graphData != undefined && this.isDropdownHidden()) {
this.delayedGraphData = graphData;
}
}
private isDropdownHidden() {
const facetDropdown = this.root.querySelector('.coveo-facet-column');
if (facetDropdown) {
return $$(<HTMLElement>facetDropdown).css('display') == 'none';
}
if ($$(this.root).hasClass('CoveoRecommendation')) {
const recommendationDropdown = $$(this.root).parents('.coveo-recommendation-column')[0] || this.root;
return $$(<HTMLElement>recommendationDropdown).css('display') == 'none';
}
return false;
}
private generateBoundary(): number[] {
if (!this.slider) {
// If the slider is not initialized, the only boundary we can get is from the state.
return this.generateBoundaryFromState();
} else {
// Else, try to get one from the slider itself. If we cant, try to return one from the state.
const boundary = this.generateBoundaryFromSlider();
if (boundary[0] == undefined && boundary[1] == undefined) {
return this.generateBoundaryFromState();
} else {
return boundary;
}
}
}
private generateBoundaryFromSlider() {
let start, end;
if (this.startOfSlider != undefined) {
start = this.startOfSlider;
}
if (this.endOfSlider != undefined) {
end = this.endOfSlider;
}
return [start, end];
}
private generateBoundaryFromState() {
let start, end;
const startFromState = this.queryStateModel.get(this.rangeQueryStateAttribute)[0];
if (startFromState != undefined) {
start = startFromState;
}
const endFromState = this.queryStateModel.get(this.rangeQueryStateAttribute)[1];
if (endFromState != undefined) {
end = endFromState;
}
if (
start != this.queryStateModel.getDefault(this.rangeQueryStateAttribute)[0] ||
end != this.queryStateModel.getDefault(this.rangeQueryStateAttribute)[1]
) {
return [start, end];
} else {
return [undefined, undefined];
}
}
private setupSliderIfNeeded(data: any) {
this.ensureDom();
if (Utils.isNullOrUndefined(this.slider)) {
if (!this.alreadySetBoundary()) {
this.trySetSliderBoundaryFromOptions();
}
if (!this.alreadySetBoundary() && data != undefined) {
this.trySetSliderBoundaryFromQueryResult(data);
}
this.trySetSliderBoundaryFromState();
this.setupSliderStateVariables();
const isInError = this.verifySetup();
if (isInError) {
this.logger.warn('Unable to initialize slider with current values', this);
} else {
this.initSlider();
this.updateQueryState();
}
}
}
private verifySetup() {
let isInError = 0;
isInError += this.initialStartOfSlider == undefined ? 1 : 0;
isInError += isNaN(this.initialStartOfSlider) ? 1 : 0;
isInError += this.initialEndOfSlider == undefined ? 1 : 0;
isInError += isNaN(this.initialEndOfSlider) ? 1 : 0;
return isInError;
}
private setupSliderStateVariables() {
if (isNaN(this.initialStartOfSlider) || isNaN(this.initialEndOfSlider)) {
this.logger.warn(
'Cannnot initialize slider with those values : start: ' + this.initialStartOfSlider + ' end: ' + this.initialEndOfSlider
);
} else {
this.initialStartOfSlider = Number(this.initialStartOfSlider);
this.initialEndOfSlider = Number(this.initialEndOfSlider);
this.startOfSlider = this.startOfSlider != undefined ? Number(this.startOfSlider) : this.initialStartOfSlider;
this.endOfSlider = this.endOfSlider != undefined ? Number(this.endOfSlider) : this.initialEndOfSlider;
this.options.start = this.initialStartOfSlider;
this.options.end = this.initialEndOfSlider;
this.queryStateModel.setNewDefault(this.rangeQueryStateAttribute, [this.initialStartOfSlider, this.initialEndOfSlider]);
}
}
private alreadySetBoundary() {
return this.startOfSlider != undefined && this.endOfSlider != undefined;
}
private trySetSliderBoundaryFromOptions() {
if (!Utils.isNullOrUndefined(this.options.start)) {
this.setupInitialSliderStateStart(this.options.start);
}
if (!Utils.isNullOrUndefined(this.options.end)) {
this.setupInitialSliderStateEnd(this.options.end);
}
}
private trySetSliderBoundaryFromState() {
const boundaries = this.getBoundariesFromState();
if (!boundaries) {
return;
}
this.setupInitialSliderStateStart(boundaries.start);
this.setupInitialSliderStateEnd(boundaries.end);
this.startOfSlider = boundaries.start;
this.endOfSlider = boundaries.end;
}
private trySetSliderBoundaryFromQueryResult(data: IQuerySuccessEventArgs) {
const groupByResults = data.results.groupByResults[this.facetQueryController.lastGroupByRequestForFullRangeIndex];
if (!groupByResults || groupByResults.values.length <= 0 || groupByResults.values[0].numberOfResults == 0) {
return;
}
let start = groupByResults.values[0].value.split('..')[0];
let end = groupByResults.values[groupByResults.values.length - 1].value.split('..')[1];
if (this.isValueADate(start) && this.isValueADate(end)) {
this.setupInitialSliderStateStart(start);
this.setupInitialSliderStateEnd(end);
return;
}
const boudariesFromState = this.getBoundariesFromState();
let startAsNumber = Number(start);
let endAsNumber = Number(end);
if (boudariesFromState) {
startAsNumber = Math.min(startAsNumber, boudariesFromState.start);
endAsNumber = Math.max(endAsNumber, boudariesFromState.end);
}
this.setupInitialSliderStateStart(startAsNumber);
this.setupInitialSliderStateEnd(endAsNumber);
}
private getBoundariesFromState() {
const stateValues = this.rangeFromUrlState || this.queryStateModel.get(this.rangeQueryStateAttribute);
if (!stateValues || stateValues[0] == undefined || stateValues[1] == undefined) {
return undefined;
}
const startAsNumber = Number(stateValues[0]);
const endAsNumber = Number(stateValues[1]);
if (isNaN(startAsNumber) || isNaN(endAsNumber)) {
return undefined;
}
return {
start: startAsNumber,
end: endAsNumber
};
}
private setupInitialSliderStateStart(value: any) {
if (this.initialStartOfSlider == undefined) {
this.initialStartOfSlider = value;
if (this.isValueADate(value)) {
this.initialStartOfSlider = this.getTimeFromAPIResponse(value);
}
}
}
private setupInitialSliderStateEnd(value: any) {
if (this.initialEndOfSlider == undefined) {
this.initialEndOfSlider = value;
if (this.isValueADate(value)) {
this.initialEndOfSlider = this.getTimeFromAPIResponse(value);
}
}
}
private getTimeFromAPIResponse(value: string) {
return new Date(value.replace('@', ' ')).getTime();
}
private isValueADate(value: any) {
return this.options.dateField && isNaN(value);
}
private updateAppearanceDependingOnState(sliding = false) {
// Defer the visual update so that we can execute it after the current call stack has resolved.
// Since this component is closely linked to DOM size calculation (width), this allows to cover some corner cases
// where the component would be visually hidden, leading to incorrect width calculation.
// For example, first query placeholder animation hiding the component, or switching between different tabs would affect the calculation otherwise.
Defer.defer(() => {
if (this.isEmpty && !this.isActive() && !sliding) {
$$(this.element).addClass('coveo-disabled-empty');
} else {
$$(this.element).removeClass('coveo-disabled-empty');
$$(this.facetHeader.eraserElement).toggle(this.isActive());
}
if (!this.isActive() && !sliding) {
$$(this.element).addClass('coveo-disabled');
} else {
$$(this.element).removeClass('coveo-disabled');
}
if (this.isActive() && this.slider) {
this.slider.onMoving();
}
});
}
private handleNuke() {
window.removeEventListener('resize', this.onResize);
}
private isFacetEmpty(groupByResults: IGroupByResult, data: IQuerySuccessEventArgs) {
return (
Utils.isNullOrUndefined(groupByResults) ||
every(groupByResults.values, value => Utils.isNullOrUndefined(value) || value.numberOfResults === 0) ||
data.results.results.length == 0
);
}
}
Initialization.registerAutoCreateComponent(FacetSlider);