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

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;