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

import noUiSlider from 'nouislider';

import FilterOption from './filter-option';
import Settings from '../../../../helpers/settings';
import Utils from '../../../../helpers/utils';
import Labels from "../../../../helpers/labels";
import Globals from '../../../../helpers/globals';
import FilterOptionEnum from '../../../../enum/filter-option-enum';
import FilterTreeEnum from '../../../../enum/filter-tree-enum';
import FilterApi from '../../../../api/filter-api';

/**
 * Filter option with displayType = 'range'
 * We use noUiSlider library: https://refreshless.com/nouislider/
 * @extends FilterOption
 */
class FilterOptionRangeSlider extends FilterOption {
	init() {
		super.init();
		this.requestInstantly = this.filterTreeType == FilterTreeEnum.FilterTreeType.VERTICAL || Settings.getSettingValue('general.requestInstantly');
	}

	/**
	 * Get the content html template for DisplayType.RANGE
	 * Depending on 'this.hideInputElement' it returns different templates with and without the input field
	 * @returns {string} Raw html template
	 */
	getBlockContentTemplate() {
		if (this.hideInputElement) {
			return `
				<div>
					<div class="boost-pfs-filter-option-range-slider"></div>
				</div>
			`;
		} else {
			if (this.style == 'style3') {
				return `
				<div class="boost-pfs-filter-block-content-inner">
					<div class="boost-pfs-filter-option-range-slider"></div>
					<div class="boost-pfs-filter-option-range-amount">
						<input class="boost-pfs-filter-option-range-amount-min" type="text" readonly />
						<input class="boost-pfs-filter-option-range-amount-max" type="text" readonly />
					</div>
				</div>
				`;
			} else {
				return `
				<div class="boost-pfs-filter-block-content-inner">
					<div class="boost-pfs-filter-option-range-amount">
						<input class="boost-pfs-filter-option-range-amount-min" type="text" />
						<div class="boost-pfs-filter-option-range-amount-split"> - </div>
						<input class="boost-pfs-filter-option-range-amount-max" type="text" />
					</div>
					<div class="boost-pfs-filter-option-range-slider"></div>
				</div>
				`;
			}

		}
	}

	isRender() {
		if (this.status == FilterOptionEnum.Status.ACTIVE) { 
			var enableOneValueRangeSlider = Settings.getSettingValue('general.oneValueRangeSlider');
			var onlyHaveOneValue = this.isNumberRangeSlider ? (this.rangeMax == this.rangeMin) : (this.valuesData.length <= 1);
			return (!onlyHaveOneValue || enableOneValueRangeSlider);
		}
		return false;
	}

	render() {
		super.render();
		// Add style class
		this.$element.addClass('boost-pfs-filter-option-range-' + this.style);

		// Render range and input values
		this.renderRangeValue();
		this.renderInputField();
	}

	/**
	 * Render the range slider using noUiSlider library
	 */
	renderRangeValue() {
		this.setCurrentValues();
		if (this.noUiSlider) {
			this.noUiSlider.set([this.currentMin, this.currentMax]);
		}
	}

	/**
	 * Render the input field.
	 * This won't be called if hideInputElement=true
	 */
	renderInputField() {
		if (this.$element && !this.hideInputElement) {
			if (!this.$minInputElement || !this.$maxInputElement) {
				this.$minInputElement = this.$element.find('.boost-pfs-filter-option-range-amount-min');
				this.$maxInputElement = this.$element.find('.boost-pfs-filter-option-range-amount-max');
			}
			var formatNumberMin = this.buildNumberLabel(this.currentMin);
			var formatNumberMax = this.buildNumberLabel(this.currentMax);
			this.$minInputElement.val(formatNumberMin);
			this.$maxInputElement.val(formatNumberMax);
		}
	}

	renderTextLabelPosition() {
		var valueLabels = this.$element.find('.noUi-value');
		var middle = 100 / (this.valuesData.length * 2);
		valueLabels.each((index, element) => {
			var leftStyle = element.style.left;
			if (typeof leftStyle == 'string') {
				var left = parseFloat(leftStyle.replace('%', ''));
				var updatedLeft = left + middle;
				element.style.left = updatedLeft + '%';
			}
		})
	}

	bindEvents() {
		super.bindEvents();
		if (this.$element) {
			
			// Creates slider
			this.$rangeSliderElement = this.$element.find('.boost-pfs-filter-option-range-slider');
			this.noUiSlider = noUiSlider.create(this.$rangeSliderElement[0], this.getSliderConfig());
			
			// Update the input field while dragging
			this.noUiSlider.on('slide', this.onDrag.bind(this));

			// Request filter when finished dragging
			this.noUiSlider.on('change', this.onFinishDragging.bind(this));

			// Bind input box event
			if (!this.hideInputElement) {
				this.$minInputElement.on('change', this.onChangeInput.bind(this, 'min'));
				this.$maxInputElement.on('change', this.onChangeInput.bind(this, 'max'));
			}

			// Render the label position for text slider (style5)
			if (!this.isNumberRangeSlider) {
				this.renderTextLabelPosition();
			}

			// Update aria-label for ADA
			this.$element.find('.boost-pfs-filter-option-range-amount-min, .noUi-handle-lower').attr('aria-label', Labels.ada.minValue);
			this.$element.find('.boost-pfs-filter-option-range-amount-max, .noUi-handle-upper').attr('aria-label', Labels.ada.maxValue);
		}
	}

	/**
	 * On changing the input field
	 * Only works for number
	 */
	onChangeInput(inputType) {
		var minInputValue = inputType == 'min' ? this.$minInputElement.val().trim() : this.currentMin.toString();
		var maxInputValue = inputType == 'max' ? this.$maxInputElement.val().trim() : this.currentMax.toString();

		// Remove delimiter from value
		if (this.thousandSeparator) {
			minInputValue = minInputValue.split(this.thousandSeparator).join('');
			maxInputValue = maxInputValue.split(this.thousandSeparator).join('');
		}
		if (this.decimalSeparator && this.decimalSeparator != '.') {
			minInputValue = minInputValue.replace(this.decimalSeparator, '.');
			maxInputValue = maxInputValue.replace(this.decimalSeparator, '.');
		}


		// If user enter a wrong value, reset the value to original
		if (!this.isNumberRangeSlider
			|| minInputValue == null || maxInputValue == null
			|| minInputValue == '' || maxInputValue == ''
			|| isNaN(minInputValue) || isNaN(maxInputValue)) {
			this.renderInputField();
			return;
		}

		minInputValue = parseFloat(minInputValue);
		maxInputValue = parseFloat(maxInputValue);
		// Min value > max value, return
		if (minInputValue > maxInputValue) {
			this.renderInputField();
			return;
		}

		// Assigns the current value
		this.currentMin = Math.max(minInputValue, this.rangeMin);
		this.currentMax = Math.min(maxInputValue, this.rangeMax);
		if (this.noUiSlider) {
			this.noUiSlider.set([this.currentMin, this.currentMax]);
		}
		// Call API
		this.onFinishDragging();
	}

	/**
	 * On dragging the slider, update the input field.
	 * This is called on noUiSlider's 'slide' event: https://refreshless.com/nouislider/events-callbacks/
	 */
	onDrag() {
		var minMaxValues = this.noUiSlider.get();
		if (minMaxValues != null) {
			// Two values slider
			if (Array.isArray(minMaxValues) && minMaxValues.length == 2) {
				if (this.isNumberRangeSlider) {
					this.currentMin = minMaxValues[0];
					this.currentMax = minMaxValues[1];
				} else {
					var min = Math.round(minMaxValues[0]);
					var max = Math.round(minMaxValues[1]);
					if (max <= min) {
						this.noUiSlider.set([this.currentMin, this.currentMax]);
					} else {
						this.currentMin = min;
						this.currentMax = max;
					}
				}
				this.renderInputField();
			// One value slider
			} else if (typeof minMaxValues == 'string' || typeof minMaxValues == 'number') {
				this.currentMin = minMaxValues;
				this.currentMax = minMaxValues;
			}
		}
	}

	/**
	 * On finish dragging the slider: update the current value, and perform API request (if this.requestInstantly = true).
	 * This is called on noUiSlider's 'change' event: https://refreshless.com/nouislider/events-callbacks/
	 */
	onFinishDragging() {
		// Set selected filter items
		if (this.isNumberRangeSlider) {
			this.filterItems.forEach(filterItem => {
				filterItem.setValue(this.currentMin, this.currentMax);
			})
		} else {
			// Can't filterItems.foreach and compare index because it's a map.
			// So we pick out the selected values in another array.
			var selectedValues = [];
			if (this.currentMin != this.rangeMin || this.currentMax != this.rangeMax) {
				for (var i = this.currentMin; i <= this.currentMax - 1; i++) {
					selectedValues.push(this.valuesData[i].key);
				}
			}

			this.filterItems.forEach(filterItem => {
				filterItem.isSelected = selectedValues.includes(filterItem.value);
			})
		}

		// Perform API request
		if (this.requestInstantly) {
			var filterValues = [];
			this.filterItems.forEach((filterItem) => {
				if (filterItem.isSelected) {
					filterValues.push(filterItem.value);
				}
			})

			FilterApi.setParam(this.filterOptionId, filterValues);
			FilterApi.setParam('page', 1);

			var eventType = 'filter';
			var eventInfo = {
				filterOptionId: this.filterOptionId,
				filterOptionValue: filterValues
			}
			FilterApi.applyFilter(eventType, eventInfo);
		}
	}

	/**
	 * Get the noUiSlider config object
	 * noUiSlider config documentation: https://refreshless.com/nouislider/slider-options/
	 * @returns {Object} - The noUiSlider config object
	 */
	getSliderConfig() {
		var sliderConfig = {
			start: this.isSingleHandle ? [this.currentMin] : [this.currentMin, this.currentMax],
			step: this.sliderStep,
			connect: true,
			snap: !this.isNumberRangeSlider,
			animate: true,
			animationDuration: 300,
			range: this.getSliderRange(),
			pips: this.getSliderPipsConfig()
		}
		if (this.isShowTooltip) {
			var tooltipFormat = {
				to: this.buildLabel.bind(this)
			}
			sliderConfig.tooltips = [tooltipFormat, tooltipFormat];
		}
		return sliderConfig;
	}

	/**
	 * Get the noUiSlider range object
	 * noUiSlider range documentation: https://refreshless.com/nouislider/slider-values/
	 * @returns {Object} - The noUiSlider range object
	 */
	getSliderRange() {
		var range = {
			'min': this.rangeMin,
			'max': this.rangeMax
		}
		if (!this.isNumberRangeSlider) {
			for (var i = 1; i < this.valuesData.length; i++) {
				var percent = i * (100 / (this.valuesData.length));
				range[percent + '%'] = i;
			}
		}
		return range;
	}

	/**
	 * Get the noUiSlider pips configs
	 * Pips are dividers in the range.
	 * noUiSlider pips documentation: https://refreshless.com/nouislider/pips/
	 * @returns {Object} - The noUiSlider pips config object
	 */
	getSliderPipsConfig() {
		var numberOfPips = this.isNumberRangeSlider ? (this.sliderRange + 1) : this.valuesData.length + 1;
		var pipConfig = {
			mode: 'count',
			values: numberOfPips,
			filter: (value, type) => { return (type == 1) ? type : -1 }, // Only render big pip
			format: {
				to: this.buildLabel.bind(this)
			}
		}
		return pipConfig;
	}

	/**
	 * Build the range slider labels (under each pips - divider)
	 * @param {string|Number} value - original value
	 * @returns {string} formatted value
	 */
	buildLabel(value) {
		var label = '';
		if (this.isNumberRangeSlider) {
			if (this.isPriceFilter) {
				label = this.buildMoneyLabel(value);
			} else {
				label = this.buildNumberLabel(value, true);
			}
		} else {
			if (Number.isInteger(value) && value >= 0 && value < this.valuesData.length) {
				label = this.valuesData[value].key;
				label = this.buildTextLabel(label);
			}
		}
		return label;
	}

	/**
	 * Build the money label. Use in price range slider.
	 * By default it uses the shop's money format.
	 * Calls buildNumberLabel() and then replace the {{amount}} with number label.
	 * @param {Number} value - The price value
	 * @returns {string} - Formatted value
	 */
	buildMoneyLabel(value) {
		var enable3rdCurrencySupport = Settings.getSettingValue('general.enable3rdCurrencySupport');
		var formattedNumber = this.buildNumberLabel(value, true);
		var label = this.moneyFormat.replace(/{{\s*(\w+)\s*}}/, formattedNumber);
		// Remove currency code
		label = label.replace(/[A-Z][A-Z][A-Z]/,'');
		if (enable3rdCurrencySupport) {
			label = Utils.moneyWrapper(label);
		}
		return label;
	}

	/**
	 * Build number label: separate thousands, decimal, fixed decimal places.
	 * @param {Number} value
	 * @param {boolean} isShortenNumber
	 * @returns {string} Formatted value
	 */
	buildNumberLabel(value, isShortenNumber) {
		if (Settings.getSettingValue('general.shortenPipsRange') && isShortenNumber) {

			var ranges = Settings.getSettingValue('general.formatPipsRange');
			if (Array.isArray(ranges) && ranges.length > 0) {
				var dividedValue = 0;
				var remainderValue = 0;

				for (var i = ranges.length - 1; i >= 0; i--) {
					var range = ranges[i];
					if (value >= range.node) {
						dividedValue = Math.floor(value / range.node).toString();
						remainderValue = Math.round(value % range.node);

						if (remainderValue > 0) {
							remainderValue = remainderValue.toString();
						} else {
							remainderValue = '';
						}

						if (remainderValue.length > range.fix) {
							remainderValue = remainderValue.substring(0, range.fix);
						}

						if (range.suffix) {
							return dividedValue + this.decimalSeparator + remainderValue + range.symbol;
						} else {
							return dividedValue + range.symbol + remainderValue;
						}
					}
				}
			}
		}

		var withoutTrailingZeros = !Settings.getSettingValue('general.removePriceDecimal');
		var label = Utils.formatNumberWithSeparator(value, this.precision, this.thousandSeparator, this.decimalSeparator, withoutTrailingZeros);
		return label;
	}

	/**
	 * Build the text label. Use in advance range slider.
	 * By default this function trims the text prefix.
	 * @param {string} value
	 * @returns {string} Formatted value
	 */
	buildTextLabel(value) {
		if (this.prefix) {
			var prefix = this.prefix.replace(/\\/g, '');
			value = value.replace(prefix, '').trim();
		}
		return value;
	}

	/**
	 * Get the money format used in building price label.
	 * Set this.moneyFormat (for label) and this.moneyFormatWithoutCurrency (for input box).
	 * It takes Globals.moneyFormat value and merge with delimiter settings.
	 */
	setMoneyFormat() {
		if (!this.isPriceFilter) return;

		// Get the money format by setting or by shop's moneyFormat
		var settingMoneyFormat = Settings.getSettingValue('general.rangeSliderMoneyFormat');
		if (settingMoneyFormat) {
			this.moneyFormat = settingMoneyFormat;
		} else {
			this.moneyFormat = '{{amount}}';
		}
	}

	/**
	 * Set the current values of the range slider.
	 * Called by setData function
	 */
	setCurrentValues() {
		var currentMin = this.rangeMin;
		var currentMax = this.rangeMax;
		var selectedValues = null;

		if (Globals.queryParams.hasOwnProperty(this.filterOptionId)) {
			// Number range slider have min & max value
			if (this.isNumberRangeSlider) {
				selectedValues = Globals.queryParams[this.filterOptionId][0].split(':');
				if (selectedValues && selectedValues.length == 2) {
					currentMin = selectedValues[0];
					currentMax = selectedValues[1];
				}

			// Text range slider have array of value
			} else {
				// Get list of selected values
				selectedValues = Globals.queryParams[this.filterOptionId];
				if (Array.isArray(selectedValues)) {
					// Get list of all values
					var values = this.valuesData.map(x => x.key);
					currentMin = this.rangeMax;
					currentMax = this.rangeMin;

					// Loop through selected values so see which range of all values are selected
					selectedValues.forEach((selectedValue) => {
						var index = values.indexOf(selectedValue);
						if (index >= this.rangeMin && index <= this.rangeMax) {
							if (index < currentMin) {
								currentMin = index;
							}
							if (index + 1 > currentMax) {
								currentMax = index + 1;
							}
						}
					})
				}
			}
		}
		this.currentMin = currentMin;
		this.currentMax = currentMax;
	}

	setDisplayStyle() {
		// Range slider style: https://www.notion.so/boostcommerce/Range-Slider-and-Range-Slider-Customization-Display-2c5ebeb19b3b46ff91e5eab93338e588
		// Style1: number - setting in admin: has input box (default)
		// Style2: number - setting in admin: no input box
		// Style3: number - setting in code: general.rangeSlidersStyle3
		// Style4: number - setting in code: general.rangeSlidersSingleHandle
		// Style5: text   - setting in code: general.advancedRangeSliders

		var style3 = Settings.getSettingValue('general.rangeSlidersStyle3');
		var singleHandle = Settings.getSettingValue('general.rangeSlidersSingleHandle');
		if (!this.isNumberRangeSlider) {
			this.style = 'style5';
			this.hideInputElement = true;
		} else if (Array.isArray(singleHandle) && singleHandle.includes(this.filterOptionId)) {
			this.style = 'style4';
			this.hideInputElement = true;
			this.isSingleHandle = true;
			this.currentMax = this.currentMin;
		} else if (Array.isArray(style3) && style3.includes(this.filterOptionId)) {
			this.style = 'style3';
			this.hideInputElement = false;
		} else if (this.hideInputElement) {
			this.style = 'style2';
			this.isShowTooltip = true;
		} else {
			this.style = 'style1';
		}

		// Change to style2 if enable support Currency (3rd party app)
		var enable3rdCurrencySupport = Settings.getSettingValue('general.enable3rdCurrencySupport');
		if (enable3rdCurrencySupport && ['style1', 'style3'].indexOf(this.style) > -1) {
			this.style = 'style2';
			this.hideInputElement = true;
			this.isShowTooltip = true;
		}
	}

	/**
	 * Prepare filter option data
	 */
	prepareFilterOptionData(data) {

		//Round up (max value) and round down (min value) to 2 decimal places for range slider
		if (data.values.max && data.values.min) {
			data.values.max = Math.ceil(data.values.max * 100) / 100;
			data.values.min = Math.floor(data.values.min * 100) / 100;
		}

		// Convert price to active currency
		if ((data.filterType == 'price' || data.filterType == 'variants_price') && data.values) {
			// Range slider
			data.values.min = Utils.convertPriceBasedOnActiveCurrency(data.values.min);
			data.values.max = Utils.convertPriceBasedOnActiveCurrency(data.values.max);
		} 
		return data;
	}

	/**
	 * Set data for FilterOptionRangeSlider. Override default setData by FilterOption.
	 * It calls super.setData(), and then set some extra data exclusive to range slider.
	 * @param data
	 */
	setData(data) {
		// Prepare filter option data
		data = this.prepareFilterOptionData(data);

		super.setData(data);

		//modify label if filter option price
		if ((this.filterType == 'price' || this.filterType == 'variants_price') && !Settings.getSettingValue('general.enable3rdCurrencySupport')) {

			// Get currency symbol
			var moneyFormat = Settings.getSettingValue('general.rangeSliderMoneyFormat');
			if (!moneyFormat) moneyFormat = Globals.moneyFormat;

			var currencySymbol = moneyFormat.replace(/<.*?>/g, '').replace(/{{.*?}}/, '');
			if (currencySymbol.length == 1) {
				this.label += " (" + currencySymbol + ")";
			}
		}

		this.isNumberRangeSlider = !Array.isArray(data.values); //data.values.min != null && data.values.max != null;

		// Set money format for price slider
		this.isPriceFilter = (data.filterType == FilterOptionEnum.FilterType.PRICE || data.filterType == FilterOptionEnum.FilterType.VARIANTS_PRICE);
		if (this.isPriceFilter) {
			this.setMoneyFormat();
		}

		// Sort values for text range slider
		if (this.isSortValues()) {
			this.sortValues(data.values);
		}

		// Keep a copy of the values
		this.valuesData = JSON.parse(JSON.stringify(data.values));

		// Number range slider (values are number - like price). Number slider has min and max values.
		if (this.isNumberRangeSlider && data.values.min != null && data.values.max != null && data.values.min != data.values.max) {
			this.hideInputElement = data.hideTextBoxes;

			// Set slider range
			this.sliderRange = parseFloat(data.sliderRange);
			if (isNaN(this.sliderRange)){
				this.sliderRange = 4;
			}

			// Set slider step
			this.sliderStep = parseFloat(data.sliderStep);
			if (isNaN(this.sliderStep) || this.sliderStep > data.values.max) {
				this.sliderStep = 1;
			}

			// Set precision (number of decimal places) based on slider step
			this.precision = 0;
			if (Math.floor(this.sliderStep) != this.sliderStep) {
				// Split slider step into decimal and none decimal parts
				var parts = this.sliderStep.toString().split(".");
				if (parts.length > 1) {
					// Get the number of precision
					this.precision = parts[1].length;
				}
				/*
				if (this.sliderStep == '0.1' || this.sliderStep == '.1') {
					this.precision = 1;
				} else if (this.sliderStep == '0.01' || this.sliderStep == '.01') {
					this.precision = 2;
				} */
			}

			this.thousandSeparator = data.sliderDelimiter ? data.sliderDelimiter : '';
			if (this.thousandSeparator == '.') {
				this.decimalSeparator = ',';
			} else {
				this.decimalSeparator = '.';
			}

			this.rangeMin = parseFloat(data.values.min);
			this.rangeMax = parseFloat(data.values.max);
			this.setCurrentValues();
			this.setDisplayStyle();

		// Text range slider (values are text - like tag or product option). Text slider has array of values.
		} else if (Array.isArray(data.values) && data.values.length > 1) {
			this.hideInputElement = true;
			this.sliderStep = 1;
			this.rangeMin = 0;
			this.rangeMax = this.valuesData.length;
			this.setCurrentValues();
			this.setDisplayStyle();
		} else {
			this.status = FilterOptionEnum.Status.DISABLED;
		}
	}
}

export default FilterOptionRangeSlider;