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;