Source: components/filter/filter-tree/filter-option/filter-option.js

import jQ from 'jquery';

import Class from '../../../../helpers/class';
import Utils from '../../../../helpers/utils';
import Settings from '../../../../helpers/settings';
import FilterOptionEnum from '../../../../enum/filter-option-enum';
import FilterTreeEnum from '../../../../enum/filter-tree-enum';
import BaseComponent from '../../../base-component';
import FilterClearButton from '../filter-option-element/filter-clear-button';
import FilterApplyButton from '../filter-option-element/filter-apply-button';
import FilterOptionItemList from '../filter-option-item/filter-option-item-list';
import FilterOptionItemBox from '../filter-option-item/filter-option-item-box';
import FilterOptionItemSwatch from '../filter-option-item/filter-option-item-swatch';
import FilterOptionItemRating from '../filter-option-item/filter-option-item-rating';
import FilterOptionItemSubCategory from '../filter-option-item/filter-option-item-sub-category';
import FilterOptionItemRangeSlider from '../filter-option-item/filter-option-item-range-slider';
import FilterViewMore from '../filter-option-element/filter-view-more';
import FilterSearchBox from '../filter-option-element/filter-search-box';
import FilterTooltip from '../filter-option-element/filter-tooltip';
import FilterScrollbar from '../filter-option-element/filter-scrollbar';
import FilterOptionItemMultiLevelCollections from "../filter-option-item/filter-option-item-multi-level-collections";
import FilterOptionItemMultiLevelTag from "../filter-option-item/filter-option-item-multi-level-tag";
import FilterCollapse from "../filter-option-element/filter-collapse";
import Labels from "../../../../helpers/labels";

/**
 * A single filter option.
 * A filter option is a field to filter by, like color, size...
 * Contains list of filter items,
 * and clear button, apply button, search box, scroll bar, view more elements.
 * @extends BaseComponent
 */
class FilterOption extends BaseComponent {
	/**
	 * Creates a new FilterOption
	 * @param {FilterTreeEnum} filterTreeType - 'vertical' or 'horizontal'
	 */
	constructor(filterTreeType) {
		super();
		/**
		 * A Map of filter option items
		 * id: filter item key
		 * value: FilterOptionItem instance
		 * @type {Map<String, FilterOptionItem>}
		 */
		this.filterItems = new Map();

		this.clearButton = null;
		this.applyButton = null;
		this.searchBox = null;
		this.filterTreeType = filterTreeType;

		this.$element = null;
		this.$filterOptionTitleElement = null;
		this.$filterOptionContentElement = null;
		this.$filterItemsContainerElement = null;

		var isVertical = this.filterTreeType == FilterTreeEnum.FilterTreeType.VERTICAL;

		this.selector = {
			filterOptionTitle: '.' + Class.filterOptionTitle + ' > button',
			filterOptionContent: '.' + Class.filterOptionContent,
			filterItemsContainer: 'ul',
			clearButtonContainer: isVertical ? ('.' + Class.filterOptionTitle) : ('.' + Class.filterOptionContent),
			applyButtonContainer: '.' + Class.filterOptionContent,
			viewMoreContainer: '.' + Class.filterOptionContent,
			searchBoxContainer: '.' + Class.filterOptionContent,
			tooltipContainer: '.' + Class.filterOptionTitle + ' > .' + Class.filterOptionTitle + '-heading',
			numberFilterItemsContainer: '.' + Class.filterOptionTitle + '-count'
		}
	};

	init() {
		// Collapse
		this.collapse = new FilterCollapse(this.filterTreeType);
		this.addComponent(this.collapse);

		// Clear button
		this.clearButton = new FilterClearButton(this.filterTreeType, FilterClearButton.ClearType.CLEAR_OPTION_VALUES);
		this.addComponent(this.clearButton);
		// Apply button
		this.applyButton = new FilterApplyButton(this.filterTreeType, FilterApplyButton.ApplyType.APPLY_OPTION_VALUES);
		this.addComponent(this.applyButton);
		// View more
		this.viewMore = new FilterViewMore(this.filterTreeType);
		this.addComponent(this.viewMore);
		// Scrollbar
		this.scrollbar = new FilterScrollbar();
		this.addComponent(this.scrollbar);
		// Search box
		this.searchBox = new FilterSearchBox();
		this.addComponent(this.searchBox);
		// Tool tip
		this.filterTooltip = new FilterTooltip(this.tooltip);
		this.addComponent(this.filterTooltip);
	}

	/**
	 * Get the filter option template.
	 * Depending on its filterTreeType, it returns different templates for 'vertical' and 'horizontal'
	 * @returns {string} Raw html template for a single filter option
	 */
	getTemplate() {
		switch (this.filterTreeType) {
			case 'vertical':
				return `
				<div class="{{class.filterOption}} {{blockTypeClass}} {{blockId}} {{class.filterScrollbar}} {{displayColumn}}">
					<div class="{{class.filterOptionTitle}}">
						<button aria-label="{{ada.filterOptionTitle}}" tabindex="0" class="{{class.button}} {{class.filterOptionTitle}}-heading">
							<span class="{{class.filterOptionTitle}}-text">
								{{blockTitle}}
								<span class="{{class.filterOptionTitle}}-count">
									{{numberAppliedFilterItems}}
								</span>
							</span>
							{{tooltip}}
						</button>
						<p class="boost-pfs-filter-selected-items-mobile"></p>
						{{clearButton}}
					</div>
					<div class="{{class.filterOptionContent}}">
						{{searchBox}}
						<div class="{{class.filterOptionContentInner}}">
							{{blockContent}}
						</div>
						{{viewMore}}
					</div>
				</div>
			`;
			case 'horizontal':
				return `
				<div class="{{class.filterOption}} {{blockTypeClass}} {{blockId}} {{class.filterScrollbar}} {{displayColumn}}">
					<div class="{{class.filterOptionTitle}}">
						<button aria-label="{{ada.filterOptionTitle}}" tabindex="0" class="{{class.button}} {{class.filterOptionTitle}}-heading">
							<span class="{{class.filterOptionTitle}}-text">
							{{blockTitle}}
								<span class="{{class.filterOptionTitle}}-count">
									{{numberAppliedFilterItems}}
								</span>
							</span>
							{{tooltip}}
						</button>
					</div>
					<div class="{{class.filterOptionContent}}">
						<div class="{{class.filterOptionContentInner}}">
							{{blockContent}}
						</div>
						{{viewMore}}
						{{applyButton}}
						{{clearButton}}
					</div>
				</div>
			`;
			default:
				throw Error('filterTreeType is wrong');
		}
	}

	/**
	 * Get the content html template of the filter option
	 * Different filter option type (list, box, swatch, range...) has different content template.
	 * This function is overriden in every filter option type that inherits this class.
	 */
	getBlockContentTemplate() {
		throw Error('Override this method');
	}

	compileBlockContentTemplate() {
		return this.getBlockContentTemplate();
	}

	compileTemplate() {
		var slugifyLabel = Utils.slugify(this.label);
		var slugifyDisplayType = Utils.slugify(this.displayType.replace(/_/g, '-'));
		var scrollbarClass = FilterScrollbar.isEnabled(this.displayType, this.filterType, this.showMoreType) ? Class.filterHasScrollbar : Class.filterNoScrollbar;
		var columnClass = 'boost-pfs-filter-option-column-' + this.displayColumn;

		return this.getTemplate()
			.replace(/{{blockTitle}}/g, this.label)
			.replace(/{{blockTypeClass}}/g, Class.filterOption + '-' + slugifyDisplayType)
			.replace(/{{blockId}}/g, Class.filterOption + '-' + slugifyLabel)
			.replace(/{{blockContent}}/g, this.compileBlockContentTemplate())
			.replace(/{{blockContentId}}/g, Class.filterOptionContent + '-' + slugifyLabel)
			.replace(/{{displayColumn}}/g, columnClass)
			.replace(/{{class.filterOption}}/g, Class.filterOption)
			.replace(/{{class.filterOptionContent}}/g, Class.filterOptionContent)
			.replace(/{{class.filterOptionContentInner}}/g, Class.filterOptionContentInner)
			.replace(/{{class.filterOptionTitle}}/g, Class.filterOptionTitle)
			.replace(/{{class.filterScrollbar}}/g, scrollbarClass)
			.replace(/{{class.filterOptionItemList}}/g, Class.filterOptionItemList)
			.replace(/{{class.filterOptionItemListSingleList}}/g, Class.filterOptionItemListSingleList)
			.replace(/{{class.filterOptionItemListMultipleList}}/g, Class.filterOptionItemListMultipleList)
			.replace(/{{class.filterOptionItemListBox}}/g, Class.filterOptionItemListBox)
			.replace(/{{class.filterOptionItemListSwatch}}/g, Class.filterOptionItemListSwatch)
			.replace(/{{class.filterOptionItemListRating}}/g, Class.filterOptionItemListRating)
			.replace(/{{class.filterOptionMultiLevelTag}}/g, Class.filterOptionMultiLevelTag)
			.replace(/{{class.filterOptiontemListMultiLevelCollections}}/g, Class.filterOptiontemListMultiLevelCollections)
			.replace(/{{class.button}}/g, Class.button)
			.replace(/{{ada.filterOptionTitle}}/g, Labels.ada.filterOptionTitle.replace('{{filterOption}}', this.label))
			.replace(/{{tooltip}}/g, '')
			.replace(/{{clearButton}}/g, '')
			.replace(/{{applyButton}}/g, '')
			.replace(/{{searchBox}}/g, '')
			.replace(/{{viewMore}}/g, '')
			.replace(/{{filterItems}}/g, '')
			.replace(/\n/g, '')
			.replace(/\t/g, '');
	}

	isRender() {
		if (this.status == FilterOptionEnum.Status.ACTIVE) {
			// The setting name is "showSingleOption" but if set to "true", it will "Hide filter options with only one filter option value".
			var isHideFilterOptionWithOneValue = Settings.getSettingValue('general.showSingleOption');

			var numberFilterItems = 0;
			var isMultiLevel = this.displayType == FilterOptionEnum.DisplayType.MULTI_LEVEL_COLLECTIONS || this.displayType == FilterOptionEnum.DisplayType.MULTI_LEVEL_TAG;
			this.filterItems.forEach((filterItem) => {
				if (filterItem.$element && filterItem.$element.length) {
					numberFilterItems++;
					if (isMultiLevel && Array.isArray(filterItem.children) && filterItem.children.length > 0) {
						numberFilterItems += filterItem.children.length;
					}
				}
			})
			if (isHideFilterOptionWithOneValue) {
				return numberFilterItems > 1;
			} else {
				return numberFilterItems > 0;
			}
		}
		return false;
	}

	isBindEvents() {
		return !this.isBoundEvent;
	}

	render() {
		if (!this.$element) {
			// Assigns variable to use later in binding events
			this.$element = jQ(this.compileTemplate());
			this.$filterOptionContentElement = this.$element.find(this.selector.filterOptionContent);
			this.$filterOptionTitleElement = this.$element.find(this.selector.filterOptionTitle);

			// Append filter options
			this.$filterItemsContainerElement = this.$element.find(this.selector.filterItemsContainer).html('');
			this.filterItems.forEach((filterItem) => {
				if (!filterItem.isRenderOnScroll) {
					this.$filterItemsContainerElement.append(filterItem.$element);
				}
			})

			// Append search box
			this.$element.find(this.selector.searchBoxContainer).prepend(this.searchBox.$element);

			// Append view more
			this.$element.find(this.selector.viewMoreContainer).append(this.viewMore.$element)

			// Append apply button
			this.$element.find(this.selector.applyButtonContainer).append(this.applyButton.$element);

			// Append clear button
			this.$element.find(this.selector.clearButtonContainer).append(this.clearButton.$element);

			// Append tooltip
			var $tooltipContainerElement = this.$element.find(this.selector.tooltipContainer);
			$tooltipContainerElement.append(this.filterTooltip.$element);
			$tooltipContainerElement.append(this.filterTooltip.$popupElement);

			// Set collapse class
			if (this.collapse.isCollapsed) {
				this.$element.addClass('boost-pfs-filter-option-collapsed');
				this.$filterOptionContentElement.hide();
			}
		}

		// Set number of applied filter items
		this.numberAppliedFilterItems = this.getNumberAppliedFilterItems();
		this.$element.find(this.selector.numberFilterItemsContainer).html(this.numberAppliedFilterItems > 0 ? this.numberAppliedFilterItems : '');
	}

	/**
	 * Get the number of applied filter items
	 * in this filter option.
	 * @return {Number} number of applied filter items
	 */
	getNumberAppliedFilterItems() {
		var count = 0;
		var includeDisplayType = [
			FilterOptionEnum.DisplayType.LIST,
			FilterOptionEnum.DisplayType.BOX,
			FilterOptionEnum.DisplayType.SWATCH,
			FilterOptionEnum.DisplayType.RATING,
			FilterOptionEnum.DisplayType.MULTI_LEVEL_TAG];

		if (this.filterItems && includeDisplayType.includes(this.displayType)) {
			this.filterItems.forEach(filterItem => {
				if (filterItem.isSelected) {
					count++;
				}
			})
		}
		return count;
	}

	/**
	 * Set data for filter option.
	 * This will also call the sort function for filter values.
	 * @param {Object} data - One element of the array: data.filter.options from API.
	 */
	setData(data) {
		this.status = data.status;
		this.position = data.position;
		this.label = Utils.stripHtml(Utils.stripScriptTag(data.label));
		this.filterOptionId = data.filterOptionId;
		this.filterType = data.filterType;
		this.displayType = data.displayType;
		this.selectType = data.selectType;
		this.valueType = data.valueType;
		this.displayTypeItem = data.displayTypeItem;
		this.manualValues = data.manualValues ? data.manualValues : [];
		this.prefix = data.prefix;
		this.isCollapsePC = data.isCollapsePC;
		this.isCollapseMobile = data.isCollapseMobile;
		this.showSearchBoxFilterPC = data.showSearchBoxFilterPC;
		this.showSearchBoxFilterMobile = data.showSearchBoxFilterMobile;
		this.keepValuesStatic = data.keepValuesStatic;
		this.activeCollectionAll = data.activeCollectionAll;
		this.tooltip = data.tooltip;
		this.showMoreType = data.showMoreType == null || data.showMoreType == '' ? FilterOptionEnum.ShowMoreType.SCROLLBAR : data.showMoreType;
		this.sortType = data.sortType;
		this.sortManualValues = data.sortManualValues;
		this.displayAllValuesInUppercaseForm = data.displayAllValuesInUppercaseForm;
		this.useAndCondition = data.useAndCondition;
		this.showExactRating = data.showExactRating;
		this.excludePriceFromValue = data.excludePriceFromValue;
		this.starColor = Utils.stripHtml(Utils.stripScriptTag(data.starColor));
		this.displayColumn = data.displayColumn ? data.displayColumn : Settings.getSettingValue('general.filterHorizontalColumn');

		this.children = [];
		this.filterItems = new Map();

		var modifiedValues = [];
		if (data.values) {
			if (!Array.isArray(data.values)) {
				modifiedValues = [data.values];
			} else {
				modifiedValues = data.values;
			}
		} else if (data.manualValues) {
			if (!Array.isArray(data.manualValues)) {
				modifiedValues = [data.manualValues];
			} else {
				modifiedValues = data.manualValues;
			}
		}
		
		if (this.isSortValues()) {
			this.sortValues(modifiedValues);
		}

		// Add or remove from values
		this.modifyValues(modifiedValues);

		// If the array is too long, we only render the first ones
		this.markValuesAsNoRender(modifiedValues);

		modifiedValues.forEach((itemData) => {
			var item = null;
			switch (this.displayType) {
				case FilterOptionEnum.DisplayType.LIST:
					item = new FilterOptionItemList(this.filterTreeType);
					break;
				case FilterOptionEnum.DisplayType.BOX:
					item = new FilterOptionItemBox(this.filterTreeType);
					break;
				case FilterOptionEnum.DisplayType.SWATCH:
					item = new FilterOptionItemSwatch(this.filterTreeType);
					break;
				case FilterOptionEnum.DisplayType.RATING:
					item = new FilterOptionItemRating(this.filterTreeType);
					break;
				case FilterOptionEnum.DisplayType.RANGE:
					item = new FilterOptionItemRangeSlider(this.filterTreeType);
					break;
				case FilterOptionEnum.DisplayType.SUB_CATEGORY:
					item = new FilterOptionItemSubCategory(this.filterTreeType);
					break;
				case FilterOptionEnum.DisplayType.MULTI_LEVEL_COLLECTIONS:
					item = new FilterOptionItemMultiLevelCollections(this.filterTreeType);
					break;
				case FilterOptionEnum.DisplayType.MULTI_LEVEL_TAG:
					item = new FilterOptionItemMultiLevelTag(this.filterTreeType);
					break;
				default:
					break;
			}
			if (!item) {
				return;
			}
			this.addComponent(item);
			item.setData(itemData);
			var itemKey = item.key ? item.key : item.value;
			this.filterItems.set(itemKey, item);
		})
	}

	/**
	 * Check if we need to sort filter option values
	 * @returns {boolean} - Whether we need to sort values or not.
	 */
	isSortValues() {
		var alwaysSortFilterType = [
			FilterOptionEnum.FilterType.REVIEW_RATINGS,
			FilterOptionEnum.FilterType.PERCENT_SALE
		];

		if (alwaysSortFilterType.includes(this.filterType)) {
			return true;
		}

		var excludeFilterType = [
			FilterOptionEnum.FilterType.STOCK
		];
		var excludeDisplayType = [
			FilterOptionEnum.DisplayType.RANGE,
			FilterOptionEnum.DisplayType.MULTI_LEVEL_COLLECTIONS
		];
		var isSortAll = this.valueType == FilterOptionEnum.ValueType.ALL;
		var isSortManual = this.valueType != FilterOptionEnum.ValueType.ALL && (this.sortManualValues || Settings.getSettingValue('general.sortManualValues'));
		var isTextRangeSlider = this.displayType == FilterOptionEnum.DisplayType.RANGE && this.isNumberRangeSlider == false;

		return !excludeFilterType.includes(this.filterType)
			&& (!excludeDisplayType.includes(this.displayType) || isTextRangeSlider)
			&& (isSortAll || isSortManual);
	}

	/**
	 * Sort the values.
	 * This is called if isSortValues() returns true.
	 * It modifies the values array in-place.
	 * @param {Array} values - The data.values array.
	 */
	sortValues(values) {
		var sortType = this.sortType ? this.sortType : FilterOptionEnum.SortType.KEY_ASCENDING;
		var sortKey = sortType.split('-')[0];
		var secondarySortKey = sortKey == 'key' ? 'doc_count' : 'key';
		if (this.filterType == FilterOptionEnum.FilterType.COLLECTION && sortKey == 'key') {
			sortKey = 'label';
		}
		if (values && values.length > 0) {
			values.sort(function(a, b) {
				var sortRes = -1;
				if (a[sortKey] != null && b[sortKey] != null) {
					var aValue = a[sortKey].toString().toLowerCase();
					var bValue = b[sortKey].toString().toLowerCase();
					sortRes = this.naturalSortFunction(aValue, bValue);
					if (sortRes == 0 && a[secondarySortKey] != null && b[secondarySortKey] != null) {
						var aSecondaryValue = a[secondarySortKey].toString().toLowerCase();
						var bSecondaryValue = b[secondarySortKey].toString().toLowerCase();
						sortRes = this.naturalSortFunction(aSecondaryValue, bSecondaryValue);
					}
				}
				return sortRes;
			}.bind(this));
		}

		// Reverse
		if (sortType.indexOf('desc') > -1 || this.filterType == FilterOptionEnum.FilterType.REVIEW_RATINGS || this.filterType == FilterOptionEnum.FilterType.PERCENT_SALE) {
			values.reverse();
		}
	};

	/**
	 * Function to sort naturally (mixed number and text)
	 * Sort number from small to big, and then sort text alphabetically.
	 * For text mixed with number, if the text starts with a number, sort the number part first.
	 * Ex: 0, 1, 1b, 2, 2a, 2b, ab, ba
	 * @param a
	 * @param b
	 * @return {number}
	 */
	naturalSortFunction (a, b) {
		function chunkify(t) {
			var tz = [];
			var x = 0, y = -1, n = 0, i, j;

			while (i = (j = t.charAt(x++)).charCodeAt(0)) {
				var m = (i == 46 || (i >= 48 && i <= 57));
				if (m !== n) {
					tz[++y] = "";
					n = m;
				}
				tz[y] += j;
			}
			return tz;
		}

		var aa = chunkify(a);
		var bb = chunkify(b);
		for (var x = 0; aa[x] && bb[x]; x++) {
			if (aa[x] !== bb[x]) {
				var c = Number(aa[x]), d = Number(bb[x]);
				if (c == aa[x] && d == bb[x]) {
					return  c - d;
				} else {
					return (aa[x] > bb[x]) ? 1 : -1;
				}
			}
		}
		return aa.length - bb.length;
	}

	/**
	 * Modify the value list.
	 * For example: add collection All to the list.
	 * It modifies the values array in-place.
	 * @param {Array} values - The data.values array.
	 */
	modifyValues(values) {
		if (this.filterType == FilterOptionEnum.FilterType.COLLECTION) {
			if (this.activeCollectionAll) {
				// Check if collection 'All' is already in the list
				var hasCollectionAll = values.some(x => x.handle == 'all');
				if (!hasCollectionAll) {
					var collectionAllData = {
						key: '0',
						label: Labels.collectionAll,
						handle: 'all'
					};
					values.unshift(collectionAllData);
				}
			}

			// Change sort_order data
			values.forEach(value => {
				if (value.sort_order) {
					if (value.sort_order.endsWith('-desc')) {
						value.sort_order = value.sort_order.replace(/-desc$/, '-descending');
					} else if (value.sort_order.endsWith('-asc')) {
						value.sort_order = value.sort_order.replace(/-asc$/, '-ascending');
					}
					if (value.sort_order.startsWith('alpha')) {
						value.sort_order = value.sort_order.replace(/alpha/, 'title');
					}
				}
			})

			// Remove values with doc_count = 0 when don't show out of stock
			if (!this.keepValuesStatic && !Settings.getSettingValue('general.showOutOfStockOption')) {
				for (var i = values.length - 1; i >= 0; i--) {
					var value = values[i];
				    if (value.doc_count == 0) {
				        values.splice(i, 1);
				    }
				}
			}
		}
	}

	/**
	 * Mark values in the array as no render.
	 * If we have more than scrollFirstLoadLength filter values,
	 * and showMoreType=='scrollbar',
	 * don't render all values at once, but append them on scroll.
	 * @param {Array} values - The data.values array.
	 */
	markValuesAsNoRender(values) {
		var scrollFirstLoadLength = Settings.getSettingValue('general.scrollFirstLoadLength');
		var supportDisplayTypes = [FilterOptionEnum.DisplayType.LIST, FilterOptionEnum.DisplayType.BOX, FilterOptionEnum.DisplayType.SWATCH];

		if (Utils.isMobile()) {
			supportDisplayTypes = [FilterOptionEnum.DisplayType.LIST, FilterOptionEnum.DisplayType.SWATCH];
		} else {
			if (this.filterTreeType == FilterTreeEnum.FilterTreeType.VERTICAL) {
				supportDisplayTypes = [FilterOptionEnum.DisplayType.LIST, FilterOptionEnum.DisplayType.BOX];
			}
		}

		if (Array.isArray(values) && values.length > scrollFirstLoadLength
			&& supportDisplayTypes.includes(this.displayType)
			&& FilterScrollbar.isEnabled(this.displayType, this.filterType, this.showMoreType)) {

			this.isLoadMoreOnScroll = true;
			values.forEach((value, index) => {
				value.isRenderOnScroll = index >= scrollFirstLoadLength;
			})
		}
	}
}

export default FilterOption;