UNPKG

coveo-search-ui

Version:

Coveo JavaScript Search Framework

978 lines (864 loc) • 36.5 kB
import { Component } from '../Base/Component'; import { l } from '../../strings/Strings'; import { ComponentOptions } from '../Base/ComponentOptions'; import { IFieldOption } from '../Base/IComponentOptions'; import { IComponentBindings } from '../Base/ComponentBindings'; import { $$, Dom } from '../../utils/Dom'; import { Initialization } from '../Base/Initialization'; import { exportGlobally } from '../../GlobalExports'; import { CategoryFacetTemplates } from './CategoryFacetTemplates'; import { CategoryValueRoot } from './CategoryValueRoot'; import { CategoryFacetQueryController } from '../../controllers/CategoryFacetQueryController'; import { SVGDom } from '../../utils/SVGDom'; import { SVGIcons } from '../../utils/SVGIcons'; import { QueryStateModel } from '../../models/QueryStateModel'; import 'styling/_CategoryFacet'; import { IAttributesChangedEventArg, MODEL_EVENTS } from '../../models/Model'; import { Utils } from '../../utils/Utils'; import { CategoryValue, CategoryValueParent } from './CategoryValue'; import { pluck, reduce, find, first, last, contains, isArray, keys } from 'underscore'; import { Assert } from '../../misc/Assert'; import { QueryEvents, IBuildingQueryEventArgs, IQuerySuccessEventArgs } from '../../events/QueryEvents'; import { CategoryFacetSearch } from './CategoryFacetSearch'; import { ICategoryFacetResult } from '../../rest/CategoryFacetResult'; import { BreadcrumbEvents, IPopulateBreadcrumbEventArgs } from '../../events/BreadcrumbEvents'; import { CategoryFacetBreadcrumb } from './CategoryFacetBreadcrumb'; import { ICategoryFacetValue } from '../../rest/CategoryFacetValue'; import { ISearchEndpoint } from '../../rest/SearchEndpointInterface'; import { IAnalyticsCategoryFacetMeta, analyticsActionCauseList, IAnalyticsActionCause, IAnalyticsFacetMeta } from '../Analytics/AnalyticsActionListMeta'; import { CategoryFacetDebug } from './CategoryFacetDebug'; import { QueryBuilder } from '../Base/QueryBuilder'; import { IAutoLayoutAdjustableInsideFacetColumn } from '../SearchInterface/FacetColumnAutoLayoutAdjustment'; import { ResponsiveFacets } from '../ResponsiveComponents/ResponsiveFacets'; import { IResponsiveComponentOptions } from '../ResponsiveComponents/ResponsiveComponentsManager'; import { ResponsiveFacetOptions } from '../ResponsiveComponents/ResponsiveFacetOptions'; import { CategoryFacetHeader } from './CategoryFacetHeader'; import { AccessibleButton } from '../../utils/AccessibleButton'; import { IStringMap } from '../../rest/GenericParam'; import { DependsOnManager, IDependentFacet, IDependsOnCompatibleFacetOptions, IDependentFacetCondition } from '../../utils/DependsOnManager'; import { ResultListUtils } from '../../utils/ResultListUtils'; import { CategoryFacetValuesTree } from './CategoryFacetValuesTree'; export interface ICategoryFacetOptions extends IResponsiveComponentOptions, IDependsOnCompatibleFacetOptions { field: IFieldOption; title?: string; numberOfResultsInFacetSearch?: number; id?: string; enableFacetSearch?: boolean; facetSearchDelay?: number; numberOfValues?: number; injectionDepth?: number; enableMoreLess?: boolean; pageSize?: number; delimitingCharacter?: string; debug?: boolean; basePath?: string[]; maximumDepth?: number; valueCaption?: IStringMap<string>; dependsOn?: string; displaySearchOnTop?: boolean; displaySearchButton?: boolean; } export type CategoryValueDescriptor = { value: string; count: number; path: string[]; }; /** * The `CategoryFacet` component is a facet that renders values in a hierarchical fashion. It determines the filter to apply depending on the * current selected path of values. * * The path is a sequence of values that leads to a specific value in the hierarchy. * It is an array listing all the parents of a file (e.g., `['c', 'folder1']` for the `c:\folder1\text1.txt` file). * * This facet requires a [`field`]{@link CategoryFacet.options.field} with a special format to work correctly (see [Using the Category Facet Component](https://docs.coveo.com/en/2667)). * * @notSupportedIn salesforcefree * @availablesince [January 2019 Release (v2.5395.12)](https://docs.coveo.com/en/3277/#january-2019-release-v2539512) */ export class CategoryFacet extends Component implements IAutoLayoutAdjustableInsideFacetColumn { static doExport = () => { exportGlobally({ CategoryFacet }); }; static ID = 'CategoryFacet'; /** * The options for the component * @componentOptions */ static options: ICategoryFacetOptions = { /** * The index field whose values the facet should use. The field values should have the form: * `the; the|path; the|path|to; the|path|to|given; the|path|to|given|item;` * where the delimiting character is `|`. This default delimiting character can be changed using the [delimitingCharacter]{@link CategoryFacet.options.delimitingCharacter} option. * * To help you verify whether your fields are setup correctly, see the {@link CategoryFacet.options.debug} option * and the {@link CategoryFacet.debugValue} method. * * See [Using the Category Facet Component](https://docs.coveo.com/en/2667). */ field: ComponentOptions.buildFieldOption({ required: true }), /** * The title to display at the top of the facet. * * Default value is the localized string for `NoTitle`. */ title: ComponentOptions.buildLocalizedStringOption({ localizedString: () => l('NoTitle') }), /** * The maximum number of field values to display by default in the facet before the user * clicks the arrow to show more. * * See also the [`enableMoreLess`]{@link CategoryFacet.options.enableMoreLess} option. */ numberOfValues: ComponentOptions.buildNumberOption({ defaultValue: 5, min: 0, section: 'CommonOptions' }), /** * Whether to display a search box at the bottom of the facet for searching among the available facet * [`field`]{@link CategoryFacet.options.field} values. * * See also the [`facetSearchDelay`]{@link CategoryFacet.options.facetSearchDelay}, and * [`numberOfResultsInFacetSearch`]{@link CategoryFacet.options.numberOfResultsInFacetSearch} options. * * * Default value is `true`. */ enableFacetSearch: ComponentOptions.buildBooleanOption({ defaultValue: true }), /** * A unique identifier for the facet. 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 on the same page, you should specify an `id` value for at least one of * those two facets. This `id` must be unique among the facets. * * Default value is the [`field`]{@link CategoryFacet.options.field} option value. */ id: ComponentOptions.buildStringOption({ postProcessing: (value, options: ICategoryFacetOptions) => value || (options.field as string) }), /** * The *injection depth* to use. * * The injection depth determines how many results to scan in the index to ensure that the category facet lists all potential * facet values. Increasing this value enhances the accuracy of the listed values at the cost of performance. * * Default value is `1000`. Minimum value is `1000`. * @notSupportedIn salesforcefree * * @examples 1500 */ injectionDepth: ComponentOptions.buildNumberOption({ defaultValue: 1000, min: 1000 }), /** * If the [`enableFacetSearch`]{@link CategoryFacet.options.enableFacetSearch} option is `true`, specifies the number of * values to display in the facet search results popup. * * Default value is `15`. Minimum value is `1`. */ numberOfResultsInFacetSearch: ComponentOptions.buildNumberOption({ defaultValue: 15, min: 1 }), /** * If the [`enableFacetSearch`]{@link CategoryFacet.options.enableFacetSearch} option is `true`, specifies the delay (in * milliseconds) before sending a search request to the server when the user starts typing in the category facet search box. * * Specifying a smaller value makes results appear faster. However, chances of having to cancel many requests * sent to the server increase as the user keeps on typing new characters. * * Default value is `100`. Minimum value is `0`. */ facetSearchDelay: ComponentOptions.buildNumberOption({ defaultValue: 100, min: 0 }), /** * Whether to enable the **More** and **Less** buttons in the Facet. * * See also the [`pageSize`]{@link CategoryFacet.options.pageSize} option. * * Default value is `true`. */ enableMoreLess: ComponentOptions.buildBooleanOption({ defaultValue: true }), /** * If the [`enableMoreLess`]{@link CategoryFacet.options.enableMoreLess} option is `true`, specifies the number of * additional results to fetch when clicking the **More** button. * * Default value is `10`. Minimum value is `1`. */ pageSize: ComponentOptions.buildNumberOption({ defaultValue: 10, min: 1, depend: 'enableMoreLess' }), /** * The character that specifies the hierarchical dependency. * * **Example:** * * If your field has the following values: * * `@field: c; c>folder2; c>folder2>folder3;` * * The delimiting character is `>`. * * Default value is `|`. */ delimitingCharacter: ComponentOptions.buildStringOption({ defaultValue: '|' }), /** * The path to use as the path prefix for every query. * * **Example:** * * You have the following files indexed on a file system: * ``` * c:\ * folder1\ * text1.txt * folder2\ * folder3\ * text2.txt * ``` * Setting the `basePath` to `c` would display `folder1` and `folder2` in the `CategoryFacet`, but omit `c`. * * This options accepts an array of values. To specify a "deeper" starting path in your tree, you need to use comma-separated values. * * For example, setting `data-base-path="c,folder1"` on the component markup would display `folder3` in the `CategoryFacet`, but omit `c` and `folder1`. * */ basePath: ComponentOptions.buildListOption<string>({ defaultValue: [] }), /** * The maximum number of levels to traverse in the hierarchy. * This option does not count the length of the base path. The depth depends on what is shown in the interface. * * Default value is `Number.MAX_VALUE`. */ maximumDepth: ComponentOptions.buildNumberOption({ min: 1, defaultValue: Number.MAX_VALUE }), /** * Whether to activate field format debugging. * This options logs messages in the console for any potential encountered issues. * This option can have negative effects on performance, and should only be activated when debugging. */ debug: ComponentOptions.buildBooleanOption({ defaultValue: false }), /** * Specifies a JSON object describing a mapping of facet values to their desired captions. See * [Normalizing Facet Value Captions](https://developers.coveo.com/x/jBsvAg). * * **Note:** * If this option is specified, the facet search box will be unavailable. * * **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"), { * Facet : { * valueCaption : myValueCaptions * } * }); * ``` * * Or before the `init` call, using the ['options']{@link options} top-level function: * ```javascript * Coveo.options(document.querySelector("#search"), { * Facet : { * valueCaption : myValueCaptions * } * }); * ``` * * Or directly in the markup: * ```html * <!-- Ensure that the double quotes are properly handled in data-value-caption. --> * <div class='CoveoCategoryFacet' data-field='@myotherfield' data-value-caption='{"txt":"Text files","html":"Web page"}'></div> * ``` */ valueCaption: ComponentOptions.buildJsonOption<IStringMap<string>>({ defaultValue: {} }), /** * The [id](@link Facet.options.id) of another facet in which at least one value must be selected in order * for the dependent category facet to be visible. * * **Default:** `undefined` and the category facet does not depend on any other facet to be displayed. * * @availablesince [September 2019 Release (v2.7023)](https://docs.coveo.com/en/2990/) */ dependsOn: ComponentOptions.buildStringOption(), /** * A function that verifies whether the current state of the `dependsOn` facet allows the dependent facet to be displayed. * * If specified, the function receives a reference to the resolved `dependsOn` facet component instance as an argument, and must return a boolean. * The function's argument should typically be treated as read-only. * * By default, the dependent facet is displayed whenever one or more values are selected in its `dependsOn` facet. * * @externaldocs [Defining Dependent Facets](https://docs.coveo.com/3210/) */ dependsOnCondition: ComponentOptions.buildCustomOption<IDependentFacetCondition>( () => { return null; }, { depend: 'dependsOn', section: 'CommonOptions' } ), /** * Whether to display the facet search widget above the facet values instead of below them. * * @availablesince [July 2020 Release (v2.9373)](https://docs.coveo.com/3293/) */ displaySearchOnTop: ComponentOptions.buildBooleanOption({ defaultValue: false }), /** * Whether to display the facet search widget as a button instead of a search bar. * * @availablesince [July 2020 Release (v2.9373)](https://docs.coveo.com/3293/) */ displaySearchButton: ComponentOptions.buildBooleanOption({ defaultValue: true }), ...ResponsiveFacetOptions }; public categoryFacetQueryController: CategoryFacetQueryController; public listenToQueryStateChange = true; public categoryFacetSearch: CategoryFacetSearch; public activeCategoryValue: CategoryValue | undefined; public positionInQuery: number; public dependsOnManager: DependsOnManager; public isFieldValueCompatible = false; public static MAXIMUM_NUMBER_OF_VALUES_BEFORE_TRUNCATING = 15; public static NUMBER_OF_VALUES_TO_KEEP_AFTER_TRUNCATING = 10; private categoryValueRoot: CategoryValueRoot; private categoryFacetTemplates: CategoryFacetTemplates; private headerElement: HTMLElement; private currentPage: number; private moreLessContainer: Dom; private moreValuesToFetch: boolean = true; private showingWaitAnimation = false; private numberOfChildValuesCurrentlyDisplayed = 0; private numberOfValues: number; private categoryFacetValuesTree: CategoryFacetValuesTree; public static WAIT_ELEMENT_CLASS = 'coveo-category-facet-header-wait-animation'; constructor(public element: HTMLElement, public options: ICategoryFacetOptions, bindings?: IComponentBindings) { super(element, 'CategoryFacet', bindings); this.options = ComponentOptions.initComponentOptions(element, CategoryFacet, options); this.categoryFacetQueryController = new CategoryFacetQueryController(this); this.categoryFacetTemplates = new CategoryFacetTemplates(); this.categoryValueRoot = new CategoryValueRoot($$(this.element), this.categoryFacetTemplates, this); this.categoryValueRoot.path = this.activePath; this.currentPage = 0; this.numberOfValues = this.options.numberOfValues; this.categoryFacetValuesTree = new CategoryFacetValuesTree(); this.tryToInitFacetSearch(); if (this.options.debug) { new CategoryFacetDebug(this); } ResponsiveFacets.init(this.root, this, this.options); this.initDependsOnManager(); this.bind.onRootElement<IBuildingQueryEventArgs>(QueryEvents.buildingQuery, args => this.handleBuildingQuery(args)); this.bind.onRootElement<IQuerySuccessEventArgs>(QueryEvents.querySuccess, args => this.handleQuerySuccess(args)); this.bind.onRootElement(QueryEvents.duringQuery, () => this.addFading()); this.bind.onRootElement(QueryEvents.deferredQuerySuccess, () => this.handleDeferredQuerySuccess()); this.bind.onRootElement<IPopulateBreadcrumbEventArgs>(BreadcrumbEvents.populateBreadcrumb, args => this.handlePopulateBreadCrumb(args)); this.bind.onRootElement(BreadcrumbEvents.clearBreadcrumb, () => this.handleClearBreadcrumb()); this.buildFacetHeader(); this.initQueryStateEvents(); } public isCurrentlyDisplayed() { return $$(this.element).isVisible(); } public get activePath() { return this.queryStateModel.get(this.queryStateAttribute) || this.options.basePath; } public set activePath(newPath: string[]) { this.listenToQueryStateChange = false; this.queryStateModel.set(this.queryStateAttribute, newPath); this.listenToQueryStateChange = true; } public get queryStateAttribute() { return QueryStateModel.getFacetId(this.options.id); } public handleBuildingQuery(args: IBuildingQueryEventArgs) { this.positionInQuery = this.categoryFacetQueryController.putCategoryFacetInQueryBuilder( args.queryBuilder, this.activePath, this.numberOfValues + 1 ); } public scrollToTop() { ResultListUtils.scrollToTop(this.root); } private tryToInitFacetSearch() { if (!this.isFacetSearchAvailable) { return this.logDisabledFacetSearchWarning(); } this.categoryFacetSearch = new CategoryFacetSearch(this, this.options.displaySearchButton); } private logDisabledFacetSearchWarning() { if (this.isEnableFacetSearchFalsy) { return; } const valueCaptionAttributeName = this.getOptionAttributeName('valueCaption'); const enableFacetSearchAttributeName = this.getOptionAttributeName('enableFacetSearch'); const field = this.options.field; this.logger.warn(`The search box is disabled on the ${field} CategoryFacet. To hide this warning, either remove the ${valueCaptionAttributeName} option or set the ${enableFacetSearchAttributeName} option to "false".`); } private getOptionAttributeName(optionName: keyof ICategoryFacetOptions) { return ComponentOptions.attrNameFromName(optionName); } private get isFacetSearchAvailable() { if (this.areValueCaptionsSpecified) { return false; } if (this.isEnableFacetSearchFalsy) { return false; } return true; } private get areValueCaptionsSpecified() { const valueCaptions = this.options.valueCaption; return keys(valueCaptions).length !== 0; } private get isEnableFacetSearchFalsy() { return !this.options.enableFacetSearch; } private get isCategoryEmpty() { return !this.categoryValueRoot.children.length; } private updateAppearance() { if (this.disabled || this.isCategoryEmpty) { return this.hide(); } this.show(); } public handleQuerySuccess(args: IQuerySuccessEventArgs) { if (Utils.isNullOrUndefined(args.results.categoryFacets)) { return this.notImplementedError(); } if (Utils.isNullOrUndefined(args.results.categoryFacets[this.positionInQuery])) { return; } const numberOfRequestedValues = args.query.categoryFacets[this.positionInQuery].maximumNumberOfValues; const categoryFacetResult = args.results.categoryFacets[this.positionInQuery]; this.moreValuesToFetch = numberOfRequestedValues == categoryFacetResult.values.length; this.clear(); if (categoryFacetResult.notImplemented) { return this.notImplementedError(); } if (!categoryFacetResult.values.length && !categoryFacetResult.parentValues.length) { return; } this.renderValues(categoryFacetResult, numberOfRequestedValues); if (this.isFacetSearchAvailable) { const facetSearch = this.categoryFacetSearch.build(); this.options.displaySearchOnTop ? $$(facetSearch).insertBefore(this.categoryValueRoot.listRoot.el) : $$(facetSearch).insertAfter(this.categoryValueRoot.listRoot.el); } this.moreLessContainer = $$('div', { className: 'coveo-category-facet-more-less-container' }); $$(this.element).append(this.moreLessContainer.el); if (this.options.enableMoreLess) { this.renderMoreLess(); } if (!this.isPristine()) { $$(this.element).addClass('coveo-category-facet-non-empty-path'); } } /** * Changes the active path. * */ public changeActivePath(path: string[]) { this.activePath = path; } public async executeQuery() { this.showWaitingAnimation(); try { await this.queryController.executeQuery(); } finally { this.hideWaitingAnimation(); } } /** * Reloads the facet with the same path. */ public reload() { this.changeActivePath(this.activePath); this.logAnalyticsEvent(analyticsActionCauseList.categoryFacetReload); this.executeQuery(); } /** * Returns all the visible parent values. * @returns simple object with three fields: `value`, `count` and `path`. */ public getVisibleParentValues(): CategoryValueDescriptor[] { return this.getVisibleParentCategoryValues().map(categoryValue => categoryValue.getDescriptor()); } private getVisibleParentCategoryValues() { if (this.categoryValueRoot.children.length == 0 || this.categoryValueRoot.children[0].children.length == 0) { return []; } let currentParentvalue = this.categoryValueRoot.children[0]; const parentValues = [currentParentvalue]; while (currentParentvalue.children.length != 0 && !Utils.arrayEqual(currentParentvalue.path, this.activePath)) { currentParentvalue = currentParentvalue.children[0]; parentValues.push(currentParentvalue); } return parentValues; } /** * Shows more values according to {@link CategoryFacet.options.pageSize}. * * See the [`enableMoreLess`]{@link CategoryFacet.options.enableMoreLess}, and * [`numberOfValues`]{@link CategoryFacet.options.numberOfValues} options. */ public showMore() { if (this.moreValuesToFetch) { this.currentPage++; this.numberOfValues = this.options.numberOfValues + this.currentPage * this.options.pageSize; this.reload(); this.logAnalyticsFacetShowMoreLess(analyticsActionCauseList.facetShowMore); } } /** * Shows less values, up to the original number of values. * * See the [`enableMoreLess`]{@link CategoryFacet.options.enableMoreLess}, and * [`numberOfValues`]{@link CategoryFacet.options.numberOfValues} options. */ public showLess() { if (this.currentPage > 0) { this.currentPage--; this.numberOfValues = this.options.numberOfValues + this.currentPage * this.options.pageSize; this.reload(); this.logAnalyticsFacetShowMoreLess(analyticsActionCauseList.facetShowLess); } } /** * Returns the values at the bottom of the hierarchy. These are the values that are not yet applied to the query. * @returns simple object with three fields: `value`, `count` and `path`. */ public getAvailableValues() { if (!this.activeCategoryValue) { return []; } return this.activeCategoryValue.children.map(categoryValue => { return { value: categoryValue.categoryValueDescriptor.value, count: categoryValue.categoryValueDescriptor.count, path: categoryValue.path }; }); } /** * Selects a value from the currently available values. * If the given value to select is not in the available values, it will throw an error. */ public selectValue(value: string) { Assert.check( contains(pluck(this.getAvailableValues(), 'value'), value), 'Failed while trying to select a value that is not available.' ); const newPath = this.activePath.slice(0); newPath.push(value); this.changeActivePath(newPath); this.logAnalyticsEvent(analyticsActionCauseList.categoryFacetSelect); this.executeQuery(); } /** * Deselects the last value in the hierarchy that is applied to the query. When at the top of the hierarchy, this method does nothing. */ public deselectCurrentValue() { if (this.activePath.length == 0) { return; } const newPath = this.activePath.slice(0); newPath.pop(); this.changeActivePath(newPath); this.logAnalyticsEvent(analyticsActionCauseList.categoryFacetSelect); this.executeQuery(); } private resetPath() { this.changeActivePath(this.options.basePath); } /** * Resets the facet to its initial state. */ public reset() { this.resetPath(); this.logAnalyticsEvent(analyticsActionCauseList.categoryFacetClear); this.executeQuery(); } /** * Hides the component. */ public hide() { $$(this.element).addClass('coveo-hidden'); } /** * Shows the component. */ public show() { $$(this.element).removeClass('coveo-hidden'); } public enable() { super.enable(); this.updateAppearance(); } public disable() { super.disable(); this.updateAppearance(); } /** * Goes through any value that contains the value parameter, and verifies whether there are missing parents. * Issues are then logged in the console. * If you do not want to specify a value, you can simply enable {@link CategoryFacet.options.debug} and do an empty query. */ public async debugValue(value: string) { const queryBuilder = new QueryBuilder(); this.categoryFacetQueryController.addDebugGroupBy(queryBuilder, value); const queryResults = await this.queryController.getEndpoint().search(queryBuilder.build()); CategoryFacetDebug.analyzeResults(queryResults.groupByResults[0], this.options.delimitingCharacter); } /** * * @param value The string to find a caption for. * Returns the caption for a value or the value itself if no caption is available. */ public getCaption(value: string) { const valueCaptions = this.options.valueCaption; const caption = valueCaptions[value]; return caption ? caption : value; } public showWaitingAnimation() { this.ensureDom(); if (!this.showingWaitAnimation) { $$(this.headerElement).find('.coveo-category-facet-header-wait-animation').style.visibility = 'visible'; this.showingWaitAnimation = true; } } public hideWaitingAnimation() { this.ensureDom(); if (this.showingWaitAnimation) { $$(this.headerElement).find('.coveo-category-facet-header-wait-animation').style.visibility = 'hidden'; this.showingWaitAnimation = false; } } public logAnalyticsEvent(eventName: IAnalyticsActionCause, path = this.activePath) { this.usageAnalytics.logSearchEvent<IAnalyticsCategoryFacetMeta>(eventName, { categoryFacetId: this.options.id, categoryFacetField: this.options.field.toString(), categoryFacetPath: path, categoryFacetTitle: this.options.title }); } public getEndpoint(): ISearchEndpoint { return this.queryController.getEndpoint(); } get children(): CategoryValue[] { return this.categoryValueRoot.children; } private renderValues(categoryFacetResult: ICategoryFacetResult, numberOfRequestedValues: number) { this.categoryFacetValuesTree.storeNewValues(categoryFacetResult); let sortedParentValues = this.sortParentValues(categoryFacetResult.parentValues); let currentParentValue: CategoryValueParent = this.categoryValueRoot; let needToTruncate = false; let pathOfLastTruncatedParentValue: string[]; const numberOfItemsInFirstSlice = Math.floor(CategoryFacet.NUMBER_OF_VALUES_TO_KEEP_AFTER_TRUNCATING / 2); const numberOfItemsInSecondSlice = Math.ceil(CategoryFacet.NUMBER_OF_VALUES_TO_KEEP_AFTER_TRUNCATING / 2); sortedParentValues = this.hideBasePathInParentValues(sortedParentValues); if (this.shouldTruncate(sortedParentValues)) { pathOfLastTruncatedParentValue = this.findPathOfLastTruncatedParentValue(sortedParentValues, numberOfItemsInSecondSlice); needToTruncate = true; sortedParentValues = first(sortedParentValues, numberOfItemsInFirstSlice).concat( last(sortedParentValues, numberOfItemsInSecondSlice) ); } if (!this.isPristine()) { this.addAllCategoriesButton(); } for (let i = 0; i < sortedParentValues.length; i++) { currentParentValue = currentParentValue.renderAsParent(sortedParentValues[i]); // We do not want to make the "last" parent selectable, as clicking it would be a noop (re-selecting the same filter) const isLastParent = i == sortedParentValues.length - 1; if (!isLastParent) { (currentParentValue as CategoryValue).makeSelectable().showCollapseArrow(); } if (needToTruncate) { if (i == numberOfItemsInFirstSlice - 1) { this.addEllipsis(); } if (i == numberOfItemsInFirstSlice) { currentParentValue.path = [...pathOfLastTruncatedParentValue, sortedParentValues[i].value]; } } } const childrenValuesToRender = this.moreValuesToFetch ? categoryFacetResult.values.slice(0, numberOfRequestedValues - 1) : categoryFacetResult.values.slice(0, numberOfRequestedValues); this.numberOfChildValuesCurrentlyDisplayed = childrenValuesToRender.length; currentParentValue.renderChildren(childrenValuesToRender); this.activeCategoryValue = currentParentValue as CategoryValue; } private hideBasePathInParentValues(parentValues: ICategoryFacetValue[]) { if (Utils.arrayEqual(first(this.activePath, this.options.basePath.length), this.options.basePath)) { parentValues = last(parentValues, parentValues.length - this.options.basePath.length); } return parentValues; } private shouldTruncate(parentValues: ICategoryFacetValue[]) { return parentValues.length > CategoryFacet.MAXIMUM_NUMBER_OF_VALUES_BEFORE_TRUNCATING; } private addEllipsis() { this.categoryValueRoot.listRoot.append(this.categoryFacetTemplates.buildEllipsis().el); } private findPathOfLastTruncatedParentValue(sortedParentValues: ICategoryFacetValue[], numberOfItemsInSecondSlice: number) { const indexOfLastTruncatedParentValue = sortedParentValues.length - numberOfItemsInSecondSlice - 1; return reduce(first(sortedParentValues, indexOfLastTruncatedParentValue + 1), (path, parentValue) => [...path, parentValue.value], []); } private addAllCategoriesButton() { const allCategories = this.categoryFacetTemplates.buildAllCategoriesButton(); new AccessibleButton() .withLabel(l('AllCategories')) .withElement(allCategories) .withSelectAction(() => { this.reset(); this.scrollToTop(); }) .build(); this.categoryValueRoot.listRoot.append(allCategories.el); } private isPristine() { return Utils.arrayEqual(this.activePath, this.options.basePath); } private buildFacetHeader() { const facetHeader = new CategoryFacetHeader({ categoryFacet: this, title: this.options.title }); this.headerElement = facetHeader.build(); $$(this.element).prepend(this.headerElement); } private handleQueryStateChanged(data: IAttributesChangedEventArg) { if (this.listenToQueryStateChange) { let path = data.attributes[this.queryStateAttribute]; if (!Utils.isNullOrUndefined(path) && isArray(path) && path.length != 0) { this.activePath = path; } } } private initQueryStateEvents() { this.queryStateModel.registerNewAttribute(this.queryStateAttribute, this.options.basePath); this.bind.onQueryState<IAttributesChangedEventArg>(MODEL_EVENTS.CHANGE, undefined, data => this.handleQueryStateChanged(data)); } private initDependsOnManager() { const facetInfo: IDependentFacet = { reset: () => this.dependsOnReset(), ref: this }; this.dependsOnManager = new DependsOnManager(facetInfo); } private dependsOnReset() { this.changeActivePath(this.options.basePath); this.clear(); } private addFading() { $$(this.element).addClass('coveo-category-facet-values-fade'); } private handleDeferredQuerySuccess() { this.updateAppearance(); this.removeFading(); } private removeFading() { $$(this.element).removeClass('coveo-category-facet-values-fade'); } private notImplementedError() { const errorMessage = 'Category Facets are not supported by your current search endpoint. Disabling this component.'; this.logger.error(errorMessage); this.disable(); } private sortParentValues(parentValues: ICategoryFacetValue[]) { if (this.activePath.length != parentValues.length) { this.logger.warn('Inconsistent CategoryFacet results: Number of parent values results does not equal length of active path'); return parentValues; } const sortedParentvalues: ICategoryFacetValue[] = []; for (const pathElement of this.activePath) { const currentParentValue = find(parentValues, parentValue => parentValue.value.toLowerCase() == pathElement.toLowerCase()); if (!currentParentValue) { this.logger.warn('Inconsistent CategoryFacet results: path not consistent with parent values results'); return parentValues; } sortedParentvalues.push(currentParentValue); } return sortedParentvalues; } private renderMoreLess() { if (this.numberOfChildValuesCurrentlyDisplayed > this.options.numberOfValues) { this.moreLessContainer.append(this.buildLessButton()); } if (this.moreValuesToFetch) { this.moreLessContainer.append(this.buildMoreButton()); } } private clear() { this.categoryValueRoot.clear(); if (this.isFacetSearchAvailable) { this.categoryFacetSearch.clear(); } this.moreLessContainer && this.moreLessContainer.detach(); $$(this.element).removeClass('coveo-category-facet-non-empty-path'); } private buildMoreButton() { const svgContainer = $$('span', { className: 'coveo-facet-more-icon' }, SVGIcons.icons.arrowDown).el; SVGDom.addClassToSVGInContainer(svgContainer, 'coveo-facet-more-icon-svg'); const more = $$('div', { className: 'coveo-category-facet-more' }, svgContainer); new AccessibleButton() .withElement(more) .withSelectAction(() => this.showMore()) .withLabel(l('ShowMoreFacetResults', this.options.title)) .build(); return more.el; } private buildLessButton() { const svgContainer = $$('span', { className: 'coveo-facet-less-icon' }, SVGIcons.icons.arrowUp).el; SVGDom.addClassToSVGInContainer(svgContainer, 'coveo-facet-less-icon-svg'); const less = $$('div', { className: 'coveo-category-facet-less' }, svgContainer); new AccessibleButton() .withElement(less) .withSelectAction(() => this.showLess()) .withLabel(l('ShowLessFacetResults', this.options.title)) .build(); return less.el; } private handlePopulateBreadCrumb(args: IPopulateBreadcrumbEventArgs) { if (this.isPristine()) { return; } const lastParentValue = this.categoryFacetValuesTree.getValueForLastPartInPath(this.activePath); const descriptor: CategoryValueDescriptor = { path: this.activePath, count: lastParentValue.numberOfResults, value: lastParentValue.value }; const resetFacet = () => { this.logAnalyticsEvent(analyticsActionCauseList.breadcrumbFacet); this.reset(); }; const categoryFacetBreadcrumbBuilder = new CategoryFacetBreadcrumb(this, resetFacet, descriptor, { headingLevel: args.headingLevel }); args.breadcrumbs.push({ element: categoryFacetBreadcrumbBuilder.build() }); } private handleClearBreadcrumb() { this.changeActivePath(this.options.basePath); } private logAnalyticsFacetShowMoreLess(cause: IAnalyticsActionCause) { this.usageAnalytics.logCustomEvent<IAnalyticsFacetMeta>( cause, { facetId: this.options.id, facetField: this.options.field.toString(), facetTitle: this.options.title }, this.element ); } } Initialization.registerAutoCreateComponent(CategoryFacet); CategoryFacet.doExport();