import jQ from 'jquery';
import Globals from '../../../helpers/globals';
import BaseComponent from '../../base-component';
import FilterOptionEnum from '../../../enum/filter-option-enum';
import FilterOptionList from './filter-option/filter-option-list';
import Labels from '../../../helpers/labels';
import FilterOptionBox from './filter-option/filter-option-box';
import FilterOptionSwatch from './filter-option/filter-option-swatch';
import FilterOptionRating from './filter-option/filter-option-rating';
import Settings from '../../../helpers/settings';
import FilterRefineBy from './filter-refine-by/filter-refine-by';
import FilterTreeEnum from '../../../enum/filter-tree-enum';
import Class from '../../../helpers/class';
import FilterClearButton from "./filter-option-element/filter-clear-button";
import FilterOptionSubCategory from './filter-option/filter-option-sub-category';
import FilterOptionRangeSlider from './filter-option/filter-option-range-slider';
import Utils from '../../../helpers/utils';
import FilterOptionMultiLevelCollections from "./filter-option/filter-option-multi-level-collections";
import FilterOptionMultiLevelTag from "./filter-option/filter-option-multi-level-tag";
import Selector from "../../../helpers/selector";
import SearchResultPanels from '../filter-result/filter-result-element/search-result-panels';
import SearchResultPanelItem from '../filter-result/filter-result-element/search-result-panel-item';
/**
* A single filter tree.
* Is rendered on DOM elements with class 'boost-pfs-filter-tree'
* Auto-generate id on that DOM element with format 'boost-pfs-filter-tree-#'
* @extends BaseComponent
*/
class FilterTree extends BaseComponent {
/**
* Creates a new FilterTree
* @param {string} id Id of the DOM element where filter tree will be inserted
* @param {FilterTreeEnum} filterTreeType - 'vertical' or 'horizontal'
*/
constructor(id, filterTreeType) {
super();
this.id = id;
this.idSelector = '#' + this.id;
this.filterTreeType = filterTreeType;
this.isMobileOnly = false;
this.isDesktopOnly = false;
this.isRenderOnDOM = true;
this.collectionId = Globals.collectionId;
this.clickedFilterOption = null;
this.refineBy = null;
this.filterOptions = new Map();
this.$element = null;
this.selector = {
refineByWrapper: '.' + Class.filterRefineByWrapper,
filterOptionsWrapper: '.' + Class.filterOptionsWrapper,
clearAllButton: '.boost-pfs-filter-mobile-toolbar-bottom .' + Class.clearAllButton,
clearAllButtonContainer: '.boost-pfs-filter-mobile-toolbar-bottom',
applyAllButton: '.boost-pfs-filter-mobile-toolbar-bottom .' + Class.applyAllFilterOptionButton,
applyAllButtonContainer: '.boost-pfs-filter-mobile-footer',
closeFilter: '.' + Class.closeFilterButton + ',.' + Class.showResultFilterButton,
filterHeader: '.boost-pfs-filter-mobile-toolbar',
filterHeaderTop: '.boost-pfs-filter-mobile-toolbar .boost-pfs-filter-mobile-toolbar-top',
filterHeaderBottom: '.boost-pfs-filter-mobile-toolbar .boost-pfs-filter-mobile-toolbar-bottom',
filterFooter: '.boost-pfs-filter-mobile-footer'
}
};
/**
* Get the filter tree's html template.
* Depending on its filterTreeType, it returns different templates for 'vertical' and 'horizontal'
* @returns {string} Raw html template
*/
getTemplate() {
switch (this.filterTreeType) {
case 'vertical':
return `
<div class="boost-pfs-filter-tree-content" aria-live="polite" role="navigation" aria-label="{{label.productFilter}}">
{{header}}
<div class="{{class.filterRefineByWrapper}}">
{{refineBy}}
</div>
<div class="{{class.filterOptionsWrapper}}">
{{filterOptions}}
</div>
{{footer}}
</div>
`;
case 'horizontal':
var isTopRefineBy = Settings.getSettingValue('general.refineByHorizontalPosition') == 'top';
if (isTopRefineBy) {
return `
<div class="boost-pfs-filter-tree-content" aria-live="polite" role="navigation" aria-label="{{label.productFilter}}">
{{header}}
<div class="{{class.filterRefineByWrapper}}">
{{refineBy}}
</div>
<div class="{{class.filterOptionsWrapper}}">
{{filterOptions}}
</div>
{{footer}}
</div>
`;
} else {
return `
<div class="boost-pfs-filter-tree-content" aria-live="polite" role="navigation" aria-label="{{label.productFilter}}">
{{header}}
<div class="{{class.filterOptionsWrapper}}">
{{filterOptions}}
</div>
<div class="{{class.filterRefineByWrapper}}">
{{refineBy}}
</div>
{{footer}}
</div>
`;
}
default:
throw Error('filterTreeType is wrong');
}
};
/**
* Get the filter tree's html template for header
* @returns {string} Raw header html template
*/
getHeaderTemplate() {
return `
<div class="boost-pfs-filter-mobile-toolbar">
<div class="boost-pfs-filter-mobile-toolbar-top">
<a href="javascript:;" class="{{class.closeFilterButton}}"><span>{{label.close}}</span></a>
</div>
<div class="boost-pfs-filter-mobile-toolbar-header">{{label.refineMobile}}</div>
<div class="boost-pfs-filter-mobile-toolbar-bottom">
{{clearButton}}
</div>
</div>
`;
}
/**
* Get the filter tree's html template for footer
* @returns {string} Raw footer html template
*/
getFooterTemplate() {
return `
<div class="boost-pfs-filter-mobile-footer">
<button class="{{class.showResultFilterButton}}" type="button">{{label.showResult}}</button>
</div>
`;
}
/**
* Replaces all brackets in raw template with values
* Replaces where other components should be with empty string
* @returns {string} - Compiled template
*/
compileTemplate() {
return this.getTemplate()
.replace(/{{header}}/g, this.getHeaderTemplate())
.replace(/{{footer}}/g, this.getFooterTemplate())
.replace(/{{label.productFilter}}/g, Labels.productFilter)
.replace(/{{label.showResult}}/g, Labels.showResult)
.replace(/{{label.refineMobile}}/g, Labels.refineMobile)
.replace(/{{label.apply}}/g, Labels.apply)
.replace(/{{label.close}}/g, Labels.close)
.replace(/{{label.back}}/g, Labels.back)
.replace(/{{class.filterOptionsWrapper}}/g, Class.filterOptionsWrapper)
.replace(/{{class.filterRefineByWrapper}}/g, Class.filterRefineByWrapper)
.replace(/{{class.closeFilterButton}}/g, Class.closeFilterButton)
.replace(/{{class.showResultFilterButton}}/g, Class.showResultFilterButton)
.replace(/{{refineBy}}/g, '')
.replace(/{{clearButton}}/g, '')
.replace(/{{applyButton}}/g, '')
.replace(/{{filterOptions}}/g, '');
};
init() {
// Check if the filter tree is rendered or not
var $containerElement = jQ(this.idSelector);
if ($containerElement.length == 1) {
this.isMobileOnly = $containerElement[0].hasAttribute("data-is-mobile");
this.isDesktopOnly = $containerElement[0].hasAttribute("data-is-desktop");
// If have either mobile OR desktop, but NOT both (XOR condition)
if (!(this.isDesktopOnly && this.isMobileOnly) && (this.isMobileOnly || this.isDesktopOnly)) {
var isMobile = Utils.isMobile();
this.isRenderOnDOM = (isMobile && this.isMobileOnly) || (!isMobile && this.isDesktopOnly);
} else {
this.isDesktopOnly = false;
this.isMobileOnly = false;
}
}
this.clearAllButton = new FilterClearButton(this.filterTreeType, FilterClearButton.ClearType.CLEAR_ALL);
this.applyAllButton = new FilterApplyButton(this.filterTreeType, FilterApplyButton.ApplyType.APPLY_ALL);
}
isRender() {
return this.parent.isFetchedFilterData && this.isRenderOnDOM && SearchResultPanels.isPanelActive(SearchResultPanelItem.Enum.PRODUCT);
}
isLoopThroughChild() {
return this.isRenderOnDOM && this.parent.isFetchedFilterData;
}
render() {
if (Globals.queryParams.build_filter_tree === true && this.filterOptions) {
if (this.isRenderPartially) {
this.renderPartially();
} else {
this.renderFull();
}
this.renderRefineBy();
this.renderExtraElements();
}
};
/**
* Render the full filter tree element.
* Called once on first load, and called when user filter by collection
*/
renderFull() {
this.$element = jQ(this.compileTemplate());
this.$filterOptionsContainerElement = this.$element.find(this.selector.filterOptionsWrapper);
this.filterOptions.forEach((filterOption) => {
this.$filterOptionsContainerElement.append(filterOption.$element);
})
}
/**
* Re-render the fitler tree partially.
* Called whenever user performs filter, after first load.
*/
renderPartially() {
this.$filterOptionsContainerElement = this.$element.find(this.selector.filterOptionsWrapper);
var isLoopThroughClickedFilterOption = false;
this.clickedFilterOption.$element.siblings().remove();
this.filterOptions.forEach((filterOption) => {
if (filterOption.$element) {
if (filterOption == this.clickedFilterOption) {
isLoopThroughClickedFilterOption = true;
return;
}
if (!isLoopThroughClickedFilterOption) {
filterOption.$element.insertBefore(this.clickedFilterOption.$element);
} else {
this.$filterOptionsContainerElement.append(filterOption.$element);
}
}
})
}
/**
* Renders the refine by block of filter tree
*/
renderRefineBy() {
if (Settings.getSettingValue('general.separateRefineByFromFilter')) {
this.renderSeparateRefineBy();
} else {
this.renderAttachedRefineBy();
}
}
renderAttachedRefineBy() {
if (this.refineBy && this.$element) {
this.$refineByElementContainer = this.$element.find(this.selector.refineByWrapper);
if (this.$refineByElementContainer.length > 0) {
this.$refineByElementContainer.first().html('').append(this.refineBy.$element);
this.$refineByElement = this.refineBy.$element;
}
}
}
renderSeparateRefineBy() {
if (this.refineBy) {
var refineBySelector = this.refineBy.filterTreeType == FilterTreeEnum.FilterTreeType.VERTICAL ? Selector.filterRefineByVertical : Selector.filterRefineByHorizontal;
if (jQ(refineBySelector).length > 0) {
jQ(refineBySelector).first().html('').append(this.refineBy.$element);
}
}
}
renderExtraElements() {
if (this.$element.find(this.selector.clearAllButton).length == 0) {
this.$element.find(this.selector.clearAllButtonContainer).append(this.clearAllButton.$element);
}
if (this.$element.find(this.selector.applyAllButton).length == 0) {
this.$element.find(this.selector.applyAllButtonContainer).append(this.applyAllButton.$element);
}
}
bindEvents() {
// Bind resize
if (this.isMobileOnly || this.isDesktopOnly) {
this.resizeTimer = null;
jQ(window).on('resize', function () {
// Wait 0.5s after resizing to fire event
if (this.resizeTimer) {
clearTimeout(this.resizeTimer);
}
this.resizeTimer = setTimeout(this.onScreenResize.bind(this), 500);
}.bind(this));
}
// Close filter event
if (this.$element && this.$element.find(this.selector.closeFilter).length > 0) {
this.$element.find(this.selector.closeFilter).off('click');
this.$element.find(this.selector.closeFilter).on('click', this.onCloseFilterTree.bind(this));
}
}
/**
* Logic to re-render the filter tree on screen resize
*/
onScreenResize() {
var isMobile = Utils.isMobile();
var isRenderOnDOM = (isMobile && this.isMobileOnly) || (!isMobile && this.isDesktopOnly);
if (this.isRenderOnDOM != isRenderOnDOM) {
this.isRenderOnDOM = isRenderOnDOM;
if (this.isRenderOnDOM) {
this.isRenderPartially = false;
this.refresh();
jQ(this.idSelector).first().html('').append(this.$element);
} else {
if (this.$element) {
this.$element.detach();
this.$element = null;
}
}
}
}
/**
* Hide the filter tree with a button inside the filter tree (the close button, not the mobile button)
*/
onCloseFilterTree() {
var $filterTreeElement = jQ(this.idSelector);
if ($filterTreeElement) {
this.mobileButton.isCollapsed = true;
$filterTreeElement.removeClass(Class.filterTreeMobileOpen);
jQ('body').removeClass(Class.filterTreeOpenBody).removeClass('boost-pfs-body-no-scroll');
jQ('html').removeClass(Class.filterTreeOpenBody).removeClass('boost-pfs-body-no-scroll');
}
}
/**
* Set the filter data returned from API.
* @param {Object} data - The data.filter field from the raw API data.
*/
setData(data) {
// When filering on the same collection, save the filter option we clicked on, so we don't rebuild it
if (this.collectionId == Globals.collectionId) {
this.clickedFilterOption = this.filterOptions.get(this.parent.clickedFilterOptionId);
} else {
this.collectionId = Globals.collectionId;
this.clickedFilterOption = null;
}
this.isRenderPartially = !!(this.$element && this.clickedFilterOption && this.clickedFilterOption.$element);
// Modify/Prepare the option data
this.modifyOptionsData(data.options);
this.children = [];
this.filterOptions = new Map();
data.options.forEach((optionData) => {
// Check if option is enabled
if (optionData.status != FilterOptionEnum.Status.ACTIVE) {
return;
}
// Check if optionData has empty values
if ((Array.isArray(optionData.values) && optionData.values.length == 0)
&& (Array.isArray(optionData.manualValues) && optionData.manualValues.length == 0)) {
return;
}
// Check if option element is the one clicked on
var filterOption = null;
if (this.clickedFilterOption && optionData.filterOptionId == this.clickedFilterOption.filterOptionId) {
filterOption = this.clickedFilterOption;
}
if (filterOption == null) {
switch (optionData.displayType) {
case FilterOptionEnum.DisplayType.LIST:
filterOption = new FilterOptionList(this.filterTreeType);
break;
case FilterOptionEnum.DisplayType.BOX:
filterOption = new FilterOptionBox(this.filterTreeType);
break;
case FilterOptionEnum.DisplayType.RANGE:
filterOption = new FilterOptionRangeSlider(this.filterTreeType);
break;
case FilterOptionEnum.DisplayType.SWATCH:
filterOption = new FilterOptionSwatch(this.filterTreeType);
break;
case FilterOptionEnum.DisplayType.RATING:
filterOption = new FilterOptionRating(this.filterTreeType);
break;
case FilterOptionEnum.DisplayType.SUB_CATEGORY:
filterOption = new FilterOptionSubCategory(this.filterTreeType);
break;
case FilterOptionEnum.DisplayType.MULTI_LEVEL_COLLECTIONS:
filterOption = new FilterOptionMultiLevelCollections(this.filterTreeType);
break;
case FilterOptionEnum.DisplayType.MULTI_LEVEL_TAG:
filterOption = new FilterOptionMultiLevelTag(this.filterTreeType);
break;
default:
break;
}
if (!filterOption) {
return;
}
filterOption.setData(optionData);
}
this.addComponent(filterOption);
this.filterOptions.set(optionData.filterOptionId, filterOption);
});
if (Settings.getSettingValue('general.showRefineBy')) {
this.refineBy = new FilterRefineBy(this.filterTreeType);
this.addComponent(this.refineBy);
this.refineBy.setData();
}
this.addComponent(this.clearAllButton);
this.addComponent(this.applyAllButton);
}
/**
* Modify the options list.
* For example: Change advance range slider display type, change multi level tag display type
* It modifies the option array in-place.
* @param {Array} options - The data.options array.
*/
modifyOptionsData(options) {
var advancedRangeSliders = Settings.getSettingValue('general.advancedRangeSliders');
options.forEach((optionData) => {
// Check if option is advanced range slider
if (Array.isArray(advancedRangeSliders) && advancedRangeSliders.includes(optionData.filterOptionId)
&& optionData.selectType == FilterOptionEnum.SelectType.MULTIPLE) {
optionData.displayType = FilterOptionEnum.DisplayType.RANGE;
}
// Back end returns filterType='multi_level_tag', with displayType='list' or 'box' or 'swatch'.
// But front-end lib split components by displayType,
// so we need to re-assign displayType='multi_level_tag' instead.
if (optionData.filterType == FilterOptionEnum.FilterType.MULTI_LEVEL_TAG) {
// Assign 'list' or 'box' or 'swatch' to displayTypeItem.
switch (optionData.displayType) {
case FilterOptionEnum.DisplayType.LIST:
optionData.displayTypeItem = FilterOptionEnum.DisplayType.LIST;
break;
case FilterOptionEnum.DisplayType.BOX:
optionData.displayTypeItem = FilterOptionEnum.DisplayType.BOX;
break;
case FilterOptionEnum.DisplayType.SWATCH:
optionData.displayTypeItem = FilterOptionEnum.DisplayType.SWATCH;
break;
default:
return;
}
// Assign displayType = 'multi_level_tag'
optionData.displayType = FilterOptionEnum.DisplayType.MULTI_LEVEL_TAG;
}
// Disable filter option by vendor on vendor page, by product type on product type page
if (Utils.isVendorPage() && optionData.filterType == FilterOptionEnum.FilterType.VENDOR) {
optionData.status = FilterOptionEnum.Status.DISABLED;
} else if (Utils.isTypePage() && optionData.filterType == FilterOptionEnum.FilterType.PRODUCT_TYPE) {
optionData.status = FilterOptionEnum.Status.DISABLED;
}
// Disable filter option sub-category (use multi-level instead)
if (optionData.displayType == FilterOptionEnum.DisplayType.SUB_CATEGORY) {
optionData.status = FilterOptionEnum.Status.DISABLED;
}
})
}
}
export default FilterTree;