UNPKG

coveo-search-ui

Version:

Coveo JavaScript Search Framework

645 lines (578 loc) • 23.9 kB
import 'styling/_SimpleFilter'; import { contains, each, filter, keys, map } from 'underscore'; import { BreadcrumbEvents, IPopulateBreadcrumbEventArgs } from '../../events/BreadcrumbEvents'; import { IBuildingQueryEventArgs, IDoneBuildingQueryEventArgs, IQuerySuccessEventArgs, QueryEvents } from '../../events/QueryEvents'; import { exportGlobally } from '../../GlobalExports'; import { Assert } from '../../misc/Assert'; import { Logger } from '../../misc/Logger'; import { l } from '../../strings/Strings'; import { getHeadingTag } from '../../utils/AccessibilityUtils'; import { AccessibleButton } from '../../utils/AccessibleButton'; import { $$, Dom } from '../../utils/Dom'; import { SVGDom } from '../../utils/SVGDom'; import { SVGIcons } from '../../utils/SVGIcons'; import { analyticsActionCauseList, IAnalyticsSimpleFilterMeta } from '../Analytics/AnalyticsActionListMeta'; import { Component } from '../Base/Component'; import { IComponentBindings } from '../Base/ComponentBindings'; import { ComponentOptions } from '../Base/ComponentOptions'; import { IFieldOption } from '../Base/IComponentOptions'; import { Initialization } from '../Base/Initialization'; import { FacetSortCriterion } from '../Facet/FacetSortCriterion'; import { FacetUtils } from '../Facet/FacetUtils'; import { Checkbox } from '../FormWidgets/Checkbox'; import { SimpleFilterValues } from './SimpleFilterValues'; export interface ISimpleFilterOptions { title: string; values: string[]; field: IFieldOption; valueCaption: any; maximumNumberOfValues: number; sortCriteria: string; enableClearButton?: boolean; } interface ILabeledCheckbox { checkbox: Checkbox; label: string; } /** * The `SimpleFilter` component displays a dropdown menu containing field values which the end user can select to filter * the query results. * * The list of available field values in the dropdown menu can either be static (defined through the * [`values`]{@link SimpleFilter.options.values} option), or dynamic (automatically obtained through a * [`GroupByRequest`]{@link IGroupByRequest} operation performed at the same time as the main query). * * @availablesince [November 2017 Release (v2.3477.9)](https://docs.coveo.com/en/373/#november-2017-release-v234779) */ export class SimpleFilter extends Component { static ID = 'SimpleFilter'; static doExport = () => { exportGlobally({ SimpleFilter }); }; static simpleFilterSortCritera() { return ['score', 'occurrences', 'alphaascending', 'alphadescending', 'chisquare']; } /** * The possible options for the SimpleFilter. * @componentOptions */ static options: ISimpleFilterOptions = { /** * Specifies the maximum number of field values to display in the `SimpleFilter` dropdown menu. * * Default value is `5`. Minimum value is `0`. */ maximumNumberOfValues: ComponentOptions.buildNumberOption({ defaultValue: 5, min: 0 }), /** * Specifies a static list of field values to display in the `SimpleFilter` dropdown menu. * * This option is undefined by default, which means that the component generates a dynamic list of field values * by performing a [`GroupByRequest`]{@link IGroupByRequest} operation at the same time as the main query. */ values: ComponentOptions.buildListOption<string>(), /** * Specifies the field whose values the `SimpleFilter` should output result filters from. * * Specifying a value for this option is required for the `SimpleFilter` component to work. */ field: ComponentOptions.buildFieldOption({ required: true }), /** * Specifies the title to display for the `SimpleFilter`. * * Default value is the localized string for `NoTitle`. */ title: ComponentOptions.buildLocalizedStringOption({ localizedString: () => l('NoTitle') }), /** * Specifies a JSON object describing a mapping of `SimpleFilter` values to their desired captions. * * **Examples:** * * * You can set the option in the ['init']{@link init} call: * ```javascript * var myValueCaptions = { * "txt" : "Text files", * "html" : "Web page", * [ ... ] * }; * * Coveo.init(document.querySelector("#search"), { * SimpleFilter : { * valueCaption : myValueCaptions * } * }); * ``` * * * Or before the `init` call, using the ['options']{@link options} top-level function: * ```javascript * Coveo.options(document.querySelector("#search"), { * SimpleFilter : { * valueCaption : myValueCaptions * } * }); * ``` * * * Or directly in the markup: * ```html * <!-- Ensure that the double quotes are properly handled in `data-value-caption`. --> * <div class='CoveoSimpleFilter' data-field='@myotherfield' data-value-caption='{"txt":"Text files","html":"Web page"}'></div> * ``` */ valueCaption: ComponentOptions.buildJsonOption(), /** * * string? * The sort criteria to use. * * **Default:** `score` * * **Allowed values:** * * `score`: sort using the score value which is computed from the number of occurrences of a field value, as well as from the position * where query result items having this field value appear in the ranked query result set. When using this sort criterion, a field value * with 100 occurrences might appear after one with only 10 occurrences, if the occurrences of the latter field value tend to appear higher * in the ranked query result set. * * `occurrences`: sort by number of occurrences, with field values having the highest number of occurrences appearing first. * * `alphaascending/alphadescending`: sort alphabetically on the field values. * * `chisquare`: sort based on the relative frequency of field values in the query result set compared to their frequency in the entire index. This means that a field value that does * not appear often in the index, but does appear often in the query result set will tend to appear higher. * * @availablesince [July 2019 Release (v2.6459)](https://docs.coveo.com/en/2938/) * */ sortCriteria: ComponentOptions.buildStringOption<FacetSortCriterion>({ postProcessing: (value, options: ISimpleFilterOptions) => { const sortCriteriaToValidate = value || 'score'; if (SimpleFilter.simpleFilterSortCritera().indexOf(sortCriteriaToValidate.toLowerCase()) !== -1) { return sortCriteriaToValidate; } else { new Logger(SimpleFilter).warn( `The simpleFilter component doesn't accept ${sortCriteriaToValidate} as the value for the sortCriteria option.`, `Available option are : ${SimpleFilter.simpleFilterSortCritera().toString()}` ); return 'score'; } } }), /** * Whether to show a button to clear all selected values. * * @availablesince [March 2020 Release (v2.8521)](https://docs.coveo.com/en/3203/) */ enableClearButton: ComponentOptions.buildBooleanOption({ defaultValue: false }) }; private valueContainer: Dom; private checkboxes: ILabeledCheckbox[]; private previouslySelected: string[] = []; private circleElement: Dom; private clearElement: Dom; private backdrop: Dom; private selectTitle: Dom; private groupByRequestValues: string[] = []; private isSticky: boolean = false; private groupByBuilder: SimpleFilterValues; private shouldTriggerQuery = true; /** * Creates a new `SimpleFilter` component. Binds multiple query events as well. * @param element the HTMLElement on which to instantiate the component. * @param options The options for the `SimpleFilter` component. * @param bindings The bindings that the component requires to function normally. */ constructor(public element: HTMLElement, public options: ISimpleFilterOptions, public bindings?: IComponentBindings) { super(element, SimpleFilter.ID, bindings); this.options = ComponentOptions.initComponentOptions(element, SimpleFilter, options); this.element.title = this.options.title; this.buildContent(); new AccessibleButton() .withElement(this.element) .withClickAction((e: Event) => this.handleClick(e)) .withEnterKeyboardAction((e: KeyboardEvent) => this.handleKeyboardSelect(e)) .withBlurAction((e: MouseEvent) => this.handleBlur(e)) .withLabel(this.options.title) .build(); this.bind.onRootElement(BreadcrumbEvents.populateBreadcrumb, (args: IPopulateBreadcrumbEventArgs) => this.handlePopulateBreadcrumb(args) ); this.bind.onRootElement(BreadcrumbEvents.clearBreadcrumb, () => this.handleClearBreadcrumb()); this.bind.onRootElement(QueryEvents.buildingQuery, (args: IBuildingQueryEventArgs) => this.handleBuildingQuery(args)); this.bind.onRootElement(QueryEvents.doneBuildingQuery, (args: IDoneBuildingQueryEventArgs) => this.handleDoneBuildingQuery(args)); this.bind.onRootElement(QueryEvents.querySuccess, (args: IQuerySuccessEventArgs) => this.handleQuerySuccess(args)); } /** * Gets the `SimpleFilter` `valueContainer`. * @returns {Dom} The `SimpleFilter` valueContainer. */ public getValueContainer(): Dom { return this.valueContainer; } /** * Gets the caption of a specific field value. * @param value The field value whose caption the method should attempt to get. * @returns {any} The value caption, if available; the original value otherwise. */ public getValueCaption(value: string): string { let ret = value; if (contains(keys(this.options.valueCaption), value)) { ret = this.options.valueCaption[ret] || ret; return l(ret); } else { return FacetUtils.tryToGetTranslatedCaption(this.options.field.toString(), ret); } } /** * Gets the captions of the currently selected field values in the `SimpleFilter`. * @returns {string[]} An array containing the selected captions. */ public getSelectedCaptions(): string[] { return map(this.getSelectedValues(), (selectedValue: string) => this.getValueCaption(selectedValue)); } /** * Opens or closes the `SimpleFilter` `valueContainer`, depending on its current state. */ public toggleContainer() { $$(this.valueContainer).hasClass('coveo-simplefilter-value-container-expanded') ? this.closeContainer() : this.openContainer(); } /** * Selects the specified value. Also triggers a query, by default. * @param value The value to select. * @param triggerQuery `true` by default. If set to `false`, the method triggers no query. */ public selectValue(value: string, triggerQuery = true) { each(this.checkboxes, (labeledCheckbox: ILabeledCheckbox) => { const translated = this.getValueCaption(labeledCheckbox.label); if (labeledCheckbox.label == value || translated == value) { labeledCheckbox.checkbox.select(triggerQuery); } }); } /** * Un-selects the specified value. * @param value The value whose state the method should reset. */ public deselectValue(value: string) { each(this.checkboxes, (labeledCheckbox: ILabeledCheckbox) => { const translated = this.getValueCaption(labeledCheckbox.label); if (labeledCheckbox.label == value || translated == value) { labeledCheckbox.checkbox.reset(); } }); } /** * Selects or un-selects the specified value, depending on its current state. * @param value The value whose state the method should toggle. */ public toggleValue(value: string) { each(this.checkboxes, (labeledCheckbox: ILabeledCheckbox) => { const translated = this.getValueCaption(labeledCheckbox.label); if (labeledCheckbox.label == value || translated == value) { labeledCheckbox.checkbox.toggle(); } }); } /** * Resets the component to its original state. */ public resetSimpleFilter() { each(this.checkboxes, (labeledCheckbox: ILabeledCheckbox) => { if (labeledCheckbox.checkbox.isSelected()) { this.deselectValue(labeledCheckbox.label); } }); } /** * Opens the `SimpleFilter` `valueContainer`. */ public openContainer() { $$(this.element).addClass('coveo-simplefilter-value-container-expanded'); this.valueContainer.addClass('coveo-simplefilter-value-container-expanded'); this.refreshValueContainer(); this.isSticky = true; if (!this.backdrop.hasClass('coveo-dropdown-background-active')) { this.showBackdrop(); } } /** * Closes the `SimpleFilter` `valueContainer`. */ public closeContainer() { $$(this.element).removeClass('coveo-simplefilter-value-container-expanded'); this.valueContainer.removeClass('coveo-simplefilter-value-container-expanded'); if (this.backdrop.hasClass('coveo-dropdown-background-active')) { this.hideBackdrop(); } if (this.getSelectedLabeledCheckboxes().length == 0) { this.isSticky = false; } } public getSelectedValues() { return map(this.getSelectedLabeledCheckboxes(), (labeledCheckbox: ILabeledCheckbox) => labeledCheckbox.label); } private handleClick(e: Event) { e.stopPropagation(); if (e.target == this.element) { this.toggleContainer(); } } private handleKeyboardSelect(e: Event) { if (e.target == this.element) { this.toggleContainer(); } else { this.toggleValue($$(e.target as HTMLElement).text()); } } private handleBlur(e: MouseEvent) { const relatedTarget = e.relatedTarget as HTMLElement; if (!relatedTarget) { return; } if (!$$(relatedTarget).parent(Component.computeCssClassName(SimpleFilter))) { this.closeContainer(); } } private handleValueToggle(checkbox: Checkbox) { const selectedValues = this.getSelectedValues(); this.circleElement.text(selectedValues.length.toString()); this.circleElement.removeClass('coveo-simplefilter-circle-hidden'); this.options.enableClearButton && this.clearElement.show(); if (selectedValues.length == 1) { this.setDisplayedTitle(this.getValueCaption(selectedValues[0])); this.element.title = this.getValueCaption(selectedValues[0]); } else { this.setDisplayedTitle(this.options.title); this.element.title = this.options.title; if (selectedValues.length < 1) { this.circleElement.addClass('coveo-simplefilter-circle-hidden'); this.options.enableClearButton && this.clearElement.hide(); } } if (selectedValues.length == 0) { this.isSticky = false; } const action = checkbox.isSelected() ? analyticsActionCauseList.simpleFilterSelectValue : analyticsActionCauseList.simpleFilterDeselectValue; if (this.shouldTriggerQuery) { this.usageAnalytics.logSearchEvent<IAnalyticsSimpleFilterMeta>(action, { simpleFilterTitle: this.options.title, simpleFilterSelectedValue: checkbox.label, simpleFilterField: <string>this.options.field }); this.queryController.executeQuery(); } } private createCheckbox(label: string) { const checkbox = new Checkbox(() => { this.handleValueToggle(checkbox); }, this.getValueCaption(label)); checkbox.getElement().title = l(label); return { checkbox, label }; } private createCheckboxes() { if (this.previouslySelected.length > 0) { this.checkboxes = map(this.previouslySelected, caption => this.createCheckbox(caption)); each(this.checkboxes, checkbox => { if (this.previouslySelected.indexOf(checkbox.label) >= 0) { this.selectValue(checkbox.label, false); } }); } else if (this.options.values != undefined) { this.checkboxes = map(this.options.values, caption => this.createCheckbox(caption)); } else if (this.groupByRequestValues != undefined) { this.checkboxes = map(this.groupByRequestValues, caption => this.createCheckbox(caption)); } if ( this.options.sortCriteria.toLocaleLowerCase() === 'alphaascending' || this.options.sortCriteria.toLowerCase() === 'alphadescending' ) { this.checkboxes.sort((a, b) => a.checkbox.label.localeCompare(b.checkbox.label)); if (this.options.sortCriteria.toLowerCase() === 'alphadescending') { this.checkboxes.reverse(); } } each(this.checkboxes, result => { this.valueContainer.append(result.checkbox.getElement()); }); if (this.checkboxes.length > 0) { $$($$(this.checkboxes[this.checkboxes.length - 1].checkbox.getElement()).find('.coveo-checkbox-button')).on('blur', () => { this.closeContainer(); }); } } private createValueContainer() { this.valueContainer = $$('div', { className: 'coveo-simplefilter-value-container' }); } private buildContent() { this.createValueContainer(); this.element.appendChild(this.buildSelect()); this.element.appendChild(this.valueContainer.el); this.findOrCreateWrapper().append(this.element); this.createBackdrop(); } private buildSelect(): HTMLElement { const select = $$('span', { className: 'coveo-simplefilter-select' }); this.selectTitle = $$('span', { className: 'coveo-simplefilter-selecttext' }, this.options.title); select.append(this.selectTitle.el); select.append(this.buildCircleElement()); this.options.enableClearButton && select.append(this.buildClearElement()); select.append(this.buildSvgToggleUpIcon()); return select.el; } private buildSvgToggleUpIcon(): HTMLElement { let svgIcon = $$('span', { className: 'coveo-simplefilter-toggle-svg-container' }, SVGIcons.icons.arrowDown).el; SVGDom.addClassToSVGInContainer(svgIcon, 'coveo-simplefilter-toggle-down-svg'); return svgIcon; } private buildCircleElement(): HTMLElement { this.circleElement = $$( 'span', { className: 'coveo-simplefilter-circle coveo-simplefilter-circle-hidden' }, this.getSelectedLabeledCheckboxes().length.toString() ); return this.circleElement.el; } public buildClearElement(): HTMLElement { this.clearElement = $$( 'button', { title: l('DeselectFilterValues', this.options.title), 'aria-label': l('Clear', this.options.title), className: 'coveo-simplefilter-eraser', type: 'button' }, SVGIcons.icons.mainClear ); this.clearElement.hide(); this.clearElement.on('click', (evt: Event) => { evt.stopPropagation(); this.handleClear(); }); return this.clearElement.el; } private createBackdrop() { const backdrop = $$(this.root).find('.coveo-dropdown-background'); if (backdrop == null) { this.backdrop = $$('div', { className: 'coveo-dropdown-background' }); this.root.appendChild(this.backdrop.el); } else { this.backdrop = $$(backdrop); } this.backdrop.on('click', () => this.closeContainer()); } private handlePopulateBreadcrumb(args: IPopulateBreadcrumbEventArgs) { if (this.getSelectedLabeledCheckboxes().length > 0) { const elem = $$('div', { className: 'coveo-simplefilter-breadcrumb' }); const title = $$( getHeadingTag(args.headingLevel, 'span'), { className: 'coveo-simplefilter-breadcrumb-title' }, `${this.options.title}:` ); elem.append(title.el); const values = $$('span', { className: 'coveo-simplefilter-breadcrumb-values' }); elem.append(values.el); each(this.getSelectedLabeledCheckboxes(), selectedlabeledCheckbox => { const value = $$('span', { className: 'coveo-simplefilter-breadcrumb-value' }, this.getValueCaption(selectedlabeledCheckbox.label)); values.append(value.el); const svgContainer = $$('span', { className: 'coveo-simplefilter-breadcrumb-clear' }, SVGIcons.icons.mainClear); value.append(svgContainer.el); value.el.title = this.getValueCaption(selectedlabeledCheckbox.label); $$(value).on('click', () => this.handleRemoveFromBreadcrumb(selectedlabeledCheckbox)); }); args.breadcrumbs.push({ element: elem.el }); } } private handleRemoveFromBreadcrumb(labeledCheckbox: ILabeledCheckbox) { labeledCheckbox.checkbox.reset(); this.refreshValueContainer(); } private handleClearBreadcrumb() { // Bit of a hack with that flag, but essentially we want "clear breadcrumb" to be a global, unique event. // Not something that will log a special event for SimpleFilter (or any component) this.resetWithoutTriggeringQuery(); } private handleClear() { this.usageAnalytics.logSearchEvent<IAnalyticsSimpleFilterMeta>(analyticsActionCauseList.simpleFilterClearAll, { simpleFilterTitle: this.options.title, simpleFilterField: <string>this.options.field }); this.resetWithoutTriggeringQuery(); this.queryController.executeQuery(); } private resetWithoutTriggeringQuery() { this.shouldTriggerQuery = false; this.resetSimpleFilter(); this.shouldTriggerQuery = true; } private handleQuerySuccess(data: IQuerySuccessEventArgs) { if (data.results.results.length > 0) { this.findOrCreateWrapper().removeClass('coveo-no-results'); } else { this.findOrCreateWrapper().addClass('coveo-no-results'); } if (this.options.values == undefined) { this.groupByBuilder.groupBy(data); this.groupByRequestValues = this.groupByBuilder.getValuesFromGroupBy(); this.refreshValueContainer(); if (!$$(this.element).hasClass('coveo-simplefilter-value-container-expanded')) { this.isSticky = false; } } } private handleBuildingQuery(args: IBuildingQueryEventArgs) { Assert.exists(args); Assert.exists(args.queryBuilder); const selectedValues = this.getSelectedValues(); if (selectedValues.length > 0) { args.queryBuilder.advancedExpression.addFieldExpression(this.options.field.toString(), '==', selectedValues); } } private handleDoneBuildingQuery(data: IDoneBuildingQueryEventArgs) { if (this.options.values == undefined) { Assert.exists(data); Assert.exists(data.queryBuilder); this.previouslySelected = this.getSelectedValues(); this.groupByBuilder = new SimpleFilterValues(this, this.options); this.groupByBuilder.handleDoneBuildingQuery(data); } } private getSelectedLabeledCheckboxes(): ILabeledCheckbox[] { return filter(this.checkboxes, (labeledCheckbox: ILabeledCheckbox) => labeledCheckbox.checkbox.isSelected()); } private setDisplayedTitle(title: string) { this.selectTitle.text(this.getValueCaption(title)); } private showBackdrop() { this.backdrop.addClass('coveo-dropdown-background-active'); } private hideBackdrop() { this.backdrop.removeClass('coveo-dropdown-background-active'); } private findOrCreateWrapper() { if ($$(this.root).find('.coveo-simplefilter-header-wrapper') == null) { const wrapper = $$('div', { className: 'coveo-simplefilter-header-wrapper' }); wrapper.insertBefore(this.element); return wrapper; } else { const wrapper = $$(this.root).find('.coveo-simplefilter-header-wrapper'); return $$(wrapper); } } private refreshValueContainer() { if (!this.isSticky) { this.valueContainer.empty(); this.createCheckboxes(); } if (this.checkboxes.length == 0 && !this.isSticky) { $$(this.element).addClass('coveo-simplefilter-empty'); } else { $$(this.element).removeClass('coveo-simplefilter-empty'); } $$(this.circleElement).text(this.getSelectedLabeledCheckboxes().length.toString()); } } Initialization.registerAutoCreateComponent(SimpleFilter);