[IMP] website(_sale): filter products by price with custom range input

When browsing the eCommerce part of the website, customers
should be able to filter shop products based on base sale price.

Desired behavior after PR is merged:
The filter slider can be enabled through the customize menu and it only
filters using the product's base price (for performance reason) and takes
into account currency conversions, not pricelists and discounts. It uses a
custom-built library for the range input with 2 cursors (min. price and max. price).

The slider is hidden when all products shown on the current page
 (category, etc.) have the same price (or there is only one product).

A more advanced version of this implementation would enable the filtering to
take more factors into account.

task-2603014

closes odoo/odoo#74575

Signed-off-by: Jérémy Kersten (jke) <jke@openerp.com>
Co-authored-by: fja-odoo <fja@odoo.com>
Co-authored-by: Kamen Zhekov <kzh@odoo.com>
Co-authored-by: Romain Derie <rde@odoo.com>
This commit is contained in:
Kamen Zhekov
2021-09-01 19:07:55 +00:00
committed by Romain Derie
co-authored by fja-odoo Romain Derie
parent 322ed33d42
commit 41f7c7bc64
7 changed files with 579 additions and 5 deletions
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Lea Verou
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,345 @@
/**
* This code has been more that widely inspired by the multirange library
* which can be found on https://github.com/LeaVerou/multirange.
*
* The license file can be found in the same folder as this file.
*/
odoo.define('website_sale.multirange', function () {
'use strict';
/**
* The multirange library will display the two values as one range input with
* two cursors linked by a background. This is to be used with Bootstrap
* custom-range inputs.
*
* There is 2 number inputs on the right and left of the multirange to
* display and allow quick and precise value modifications. They are
* initialized with the same value provided to the input (min, max, step).
*
* There is 2 events that are added to the input:
* - oldRangeValue: Triggered when the user clicks on a cursor or on focus
* of the right or left number input.
* - newRangeValue: Triggered when the user release a cursor or on focus
* out of the right or left number input.
*
* The options available for the multirange are :
* - On range input or as multirange method options:
* - min: minimal value of the range. Default: 0.
* - max: maximal value of the range. Default: 100.
* - step: precision of the range. Default: 1.
* - currency: symbol preceding the displayed values. Default: Empty.
* - currencyPosition: currency before/after value. Default: "after".
* - value: the current value of the range. Default: "0,100".
*
* - As multirange method options only:
* - displayCounterInput: if we display the value. Default: true.
*
* Initialization of a multiple range input can be done in two ways:
*
* Having the inputs with the options as properties and the multiple
* property set will let the library initialize it just after DOM loaded.
*
* <input type="range" multiple="multiple" class="custom-range
* range-with-input" min=2 max=10 step=0.5 data-currency="€"
* data-currency-position="before" value="4,8"/>
*
* Providing a HTMLElement and an Object with the desired options.
*
* <input id="multi" type="range" class="custom-range"/>
*
* multirange(document.querySelector('#multi'), {
* min: 2,
* max: 10,
* step: 0.5,
* currency: "€",
* currencyPosition: "before",
* value: "4,8"
* rangeWithInput: true,
* });
*/
const HTMLInputElement = window.HTMLInputElement;
const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value");
class Multirange {
constructor(input, options = {}) {
const self = this;
/* Set default and optionnal values */
this.input = input;
this.rangeWithInput = options.rangeWithInput === true || this.input.classList.contains('range-with-input');
const value = options.value || this.input.getAttribute("value");
const values = value === null ? [] : value.split(",");
this.input.min = this.min = options.min || this.input.min || 0;
this.input.max = this.max = options.max || this.input.max || 100;
this.input.step = this.step = options.step || this.input.step || 1;
this.currency = options.currency || this.input.dataset.currency || '';
this.currencyPosition = options.currencyPosition || this.input.dataset.currencyPosition || 'after';
/* Wrap the input and add its ghost */
this.rangeDiv = document.createElement("div");
this.rangeDiv.classList = "multirange-wrapper";
this.input.parentNode.insertBefore(this.rangeDiv, this.input.nextSibling);
this.rangeDiv.appendChild(this.input);
this.ghost = this.input.cloneNode();
this.rangeDiv.appendChild(this.ghost);
this.input.classList.add("multirange", "original");
this.ghost.classList.add("multirange", "ghost");
this.input.value = values[0] || this.min;
this.ghost.value = values[1] || this.max;
this.inputTipLocator = document.createElement("div");
this.inputTipLocator.classList = "tip-locator";
this.ghostTipLocator = document.createElement("div");
this.ghostTipLocator.classList = "tip-locator";
this.rangeDiv.insertBefore(this.ghostTipLocator, this.input.nextSibling);
this.rangeDiv.insertBefore(this.inputTipLocator, this.ghost.nextSibling);
this.leftCounter = document.createElement("span");
this.leftCounter.classList = "multirange-min";
this.rightCounter = document.createElement("span");
this.rightCounter.classList = "multirange-max";
this.tipLocatorOptions = {
container: this.rangeDiv,
html: true,
};
$(this.inputTipLocator).popover(Object.assign(
this.tipLocatorOptions,
{
placement: 'top',
content: this.leftCounter
})
);
$(this.ghostTipLocator).popover(Object.assign(
this.tipLocatorOptions,
{
placement: 'bottom',
content: this.rightCounter
})
);
$(this.inputTipLocator).add($(this.ghostTipLocator)).popover('show');
/* Add the counterInput */
if (this.rangeWithInput) {
this.leftInput = document.createElement("input");
this.leftInput.type = "number";
this.leftInput.style.display = "none";
this.leftInput.min = this.min;
this.leftInput.max = this.max;
this.leftInput.step = this.step;
this.rightInput = this.leftInput.cloneNode();
this.leftInput.classList = "multirange-min";
this.rightInput.classList = "multirange-max";
this.leftCounter.parentNode.appendChild(this.leftInput);
this.rightCounter.parentNode.appendChild(this.rightInput);
}
/* Define new properties on range input to link it with ghost, especially for Safari compatibility*/
Object.defineProperty(this.input, "originalValue", descriptor.get ? descriptor : {
get: function () {
return this.value;
},
set: function (v) {
this.value = v;
}
});
Object.defineProperties(this.input, {
valueLow: {
get: function () {
return Math.min(this.originalValue, self.ghost.value);
},
set: function (v) {
this.originalValue = v;
},
enumerable: true
},
valueHigh: {
get: function () {
return Math.max(this.originalValue, self.ghost.value);
},
set: function (v) {
self.ghost.value = v;
},
enumerable: true
}
});
if (descriptor.get) {
Object.defineProperty(this.input, "value", {
get: function () {
return this.valueLow + "," + this.valueHigh;
},
set: function (v) {
const values = v.split(",");
this.valueLow = values[0];
this.valueHigh = values[1];
this.update();
},
enumerable: true
});
}
if (typeof this.input.oninput === "function") {
this.ghost.oninput = this.input.oninput.bind(this.input);
}
this.input.addEventListener("input", this.update.bind(this));
this.ghost.addEventListener("input", this.update.bind(this));
this.input.addEventListener("touchstart", this.saveOldValues.bind(this));
this.ghost.addEventListener("touchstart", this.saveOldValues.bind(this));
this.input.addEventListener("mousedown", this.saveOldValues.bind(this));
this.ghost.addEventListener("mousedown", this.saveOldValues.bind(this));
this.input.addEventListener("touchend", this.dispatchNewValueEvent.bind(this));
this.ghost.addEventListener("touchend", this.dispatchNewValueEvent.bind(this));
this.input.addEventListener("mouseup", this.dispatchNewValueEvent.bind(this));
this.ghost.addEventListener("mouseup", this.dispatchNewValueEvent.bind(this));
if (this.rangeWithInput) {
this.leftCounter.addEventListener("click", this.counterInputSwitch.bind(this));
this.rightCounter.addEventListener("click", this.counterInputSwitch.bind(this));
this.leftInput.addEventListener("blur", this.counterInputSwitch.bind(this));
this.rightInput.addEventListener("blur", this.counterInputSwitch.bind(this));
this.leftInput.addEventListener("keypress", this.elementBlurOnEnter.bind(this));
this.rightInput.addEventListener("keypress", this.elementBlurOnEnter.bind(this));
this.leftInput.addEventListener("focus", this.selectAllFocus.bind(this));
this.rightInput.addEventListener("focus", this.selectAllFocus.bind(this));
}
this.update();
$(this.rangeDiv).addClass('visible');
}
update() {
const low = 100 * (this.input.valueLow - this.min) / (this.max - this.min);
const high = 100 * (this.input.valueHigh - this.min) / (this.max - this.min);
const tipOffsetLow = 8 - (low * 0.15);
const tipOffsetHigh = 8 - (high * 0.15);
this.rangeDiv.style.setProperty("--low", low + '%');
this.rangeDiv.style.setProperty("--high", high + '%');
$(this.inputTipLocator).css({
'left': `calc(${low}% + (${tipOffsetLow}px))`,
'top': '3px'
});
$(this.ghostTipLocator).css({
'left': `calc(${high}% + (${tipOffsetHigh}px))`,
'top': '18px'
});
$(this.inputTipLocator).add($(this.ghostTipLocator)).popover('update');
this.counterInputUpdate();
}
counterInputUpdate() {
if (this.rangeWithInput) {
this.leftCounter.innerText = this.formatNumber(this.input.valueLow);
this.rightCounter.innerText = this.formatNumber(this.input.valueHigh);
this.leftInput.value = this.input.valueLow;
this.rightInput.value = this.input.valueHigh;
}
}
counterInputSwitch(ev) {
let counter = this.rightCounter;
let input = this.rightInput;
if (ev.currentTarget.classList.contains('multirange-min')) {
counter = this.leftCounter;
input = this.leftInput;
}
if (counter.style.display === "none") {
this.input.valueLow = this.leftInput.value;
this.input.valueHigh = this.rightInput.value;
this.dispatchNewValueEvent();
this.update();
counter.style.display = "";
input.style.display = "none";
} else {
counter.style.display = "none";
input.style.display = "";
this.saveOldValues();
// Hack because firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=1057858
window.setTimeout(function () {
input.focus();
}, 1);
}
}
elementBlurOnEnter(ev) {
if (ev.key === "Enter") {
ev.currentTarget.blur();
}
}
selectAllFocus(ev) {
ev.currentTarget.select();
}
dispatchNewValueEvent() {
if (this._previousMaxPrice !== this.input.valueHigh || this._previousMinPrice !== this.input.valueLow) {
this.input.dispatchEvent(new CustomEvent("newRangeValue", {
bubbles: true,
}));
}
}
saveOldValues() {
this._previousMinPrice = this.input.valueLow;
this._previousMaxPrice = this.input.valueHigh;
}
formatNumber(number) {
number = String(number).split('.');
if (number[1] && number[1].length === 1) {
number[1] += '0';
}
let formatedNumber = number[0].replace(/(?=(?:\d{3})+$)(?!\b)/g, ',') + (number[1] ? '.' + number[1] : '.00');
if (this.currency.length) {
if (this.currencyPosition === 'after') {
formatedNumber = formatedNumber + ' ' + this.currency;
} else {
formatedNumber = this.currency + ' ' + formatedNumber;
}
}
return formatedNumber;
}
}
function multirange(input, options) {
if (input.classList.contains('multirange')) {
return;
}
new Multirange(input, options);
}
return {
Multirange: Multirange,
init: multirange,
};
});
odoo.define('website_sale.multirange.instance', function (require) {
'use strict';
const publicWidget = require('web.public.widget');
const multirange = require('website_sale.multirange');
publicWidget.registry.WebsiteMultirangeInputs = publicWidget.Widget.extend({
selector: 'input[type=range][multiple]:not(.multirange)',
/**
* @override
*/
start() {
return this._super.apply(this, arguments).then(() => {
multirange.init(this.el);
});
},
});
});
@@ -0,0 +1,85 @@
// This code has been more that widely inspired by the multirange library
// which can be found on https://github.com/LeaVerou/multirange.
// The license file can be found in the same folder as this file.
input[type="range"][multiple] {
pointer-events: none;
visibility: hidden;
&::-webkit-slider-thumb {
pointer-events: auto;
}
&::-moz-range-thumb {
pointer-events: auto;
}
&.reverse {
direction: rtl;
}
&.multirange {
width: 100%;
padding: 0;
margin: 0;
display: inline-block;
vertical-align: top;
&::-webkit-slider-thumb {
height: 16px;
width: 16px;
}
&.original {
position: absolute;
&::-webkit-slider-thumb {
position: relative;
z-index: 2;
}
&::-moz-range-thumb {
transform: scale(1); /* Firefox doesn't apply position it seems */
z-index: 1;
}
}
&::-moz-range-track {
border-color: transparent; /* needed to switch Firefox to "styleable" control */
}
&.ghost {
border-radius: 45%;
position: relative;
background: var(--track-background);
--track-background: linear-gradient(to right, transparent var(--low), #{theme-color('primary')} var(--low) var(--high), transparent 0) no-repeat 50% / 100% 35%;
&::-webkit-slider-runnable-track {
background: var(--track-background);
}
&::-moz-range-track {
background: var(--track-background);
}
}
}
}
.multirange-wrapper {
position: relative;
margin: 3rem 0;
visibility: hidden;
input[type="range"][multiple] {
visibility: initial;
}
[x-out-of-boundaries] {
display: none;
}
}
.tip-locator {
position: absolute;
}
+2
View File
@@ -43,6 +43,7 @@
'website_sale/static/src/scss/website_sale.scss',
'website_sale/static/src/scss/website_mail.scss',
'website_sale/static/src/scss/website_sale_frontend.scss',
'website/static/lib/multirange/multirange_custom.scss',
'sale/static/src/scss/sale_portal.scss',
'sale/static/src/scss/product_configurator.scss',
'sale/static/src/js/variant_mixin.js',
@@ -53,6 +54,7 @@
'website_sale/static/src/js/website_sale_validate.js',
'website_sale/static/src/js/website_sale_recently_viewed.js',
'website_sale/static/src/js/website_sale_tracking.js',
'website/static/lib/multirange/multirange_custom.js',
],
'web._assets_primary_variables': [
'website_sale/static/src/scss/primary_variables.scss',
+75 -5
View File
@@ -3,6 +3,7 @@
import json
import logging
from werkzeug.exceptions import Forbidden, NotFound
from werkzeug.urls import url_decode, url_encode, url_parse
from odoo import fields, http, SUPERUSER_ID, tools, _
from odoo.fields import Command
@@ -207,8 +208,17 @@ class WebsiteSale(http.Controller):
'''/shop/category/<model("product.public.category"):category>''',
'''/shop/category/<model("product.public.category"):category>/page/<int:page>'''
], type='http', auth="public", website=True, sitemap=sitemap_shop)
def shop(self, page=0, category=None, search='', ppg=False, **post):
def shop(self, page=0, category=None, search='', min_price=0.0, max_price=0.0, ppg=False, **post):
add_qty = int(post.get('add_qty', 1))
try:
min_price = float(min_price)
except ValueError:
min_price = 0
try:
max_price = float(max_price)
except ValueError:
max_price = 0
Category = request.env['product.public.category']
if category:
category = Category.search([('id', '=', int(category))], limit=1)
@@ -232,10 +242,9 @@ class WebsiteSale(http.Controller):
attrib_values = [[int(x) for x in v.split("-")] for v in attrib_list if v]
attributes_ids = {v[0] for v in attrib_values}
attrib_set = {v[1] for v in attrib_values}
domain = self._get_search_domain(search, category, attrib_values)
keep = QueryURL('/shop', category=category and int(category), search=search, attrib=attrib_list, order=post.get('order'))
keep = QueryURL('/shop', category=category and int(category), search=search, attrib=attrib_list, min_price=min_price, max_price=max_price, order=post.get('order'))
pricelist_context, pricelist = self._get_pricelist_context()
@@ -249,6 +258,37 @@ class WebsiteSale(http.Controller):
Product = request.env['product.template'].with_context(bin_size=True)
filter_by_price_enabled = request.website.is_view_active('website_sale.filter_products_price')
if filter_by_price_enabled:
company_currency = request.website.company_id.currency_id
conversion_rate = request.env['res.currency']._get_conversion_rate(company_currency, pricelist.currency_id, request.website.company_id, fields.Date.today())
# This is ~4 times more efficient than a search for the cheapest and most expensive products
from_clause, where_clause, where_params = Product._where_calc(domain).get_sql()
query = f"""
SELECT COALESCE(MIN(list_price), 0) * {conversion_rate}, COALESCE(MAX(list_price), 0) * {conversion_rate}
FROM {from_clause}
WHERE {where_clause}
"""
request.env.cr.execute(query, where_params)
available_min_price, available_max_price = request.env.cr.fetchone()
if min_price or max_price:
# The if/else condition in the min_price / max_price value assignment
# tackles the case where we switch to a list of products with different
# available min / max prices than the ones set in the previous page.
# In order to have logical results and not yield empty product lists, the
# price filter is set to their respective available prices when the specified
# min exceeds the max, and / or the specified max is lower than the available min.
if min_price:
min_price = min_price if min_price <= available_max_price else available_min_price
post['min_price'] = min_price
domain = expression.AND([domain, [('list_price', '>=', min_price / conversion_rate)]])
if max_price:
max_price = max_price if max_price >= available_min_price else available_max_price
post['max_price'] = max_price
domain = expression.AND([domain, [('list_price', '<=', max_price / conversion_rate)]])
search_product = Product.search(domain, order=self._get_search_order(post))
website_domain = request.website.website_domain()
categs_domain = [('parent_id', '=', False)] + website_domain
@@ -300,6 +340,11 @@ class WebsiteSale(http.Controller):
'search_categories_ids': search_categories.ids,
'layout_mode': layout_mode,
}
if filter_by_price_enabled:
values['min_price'] = min_price or available_min_price
values['max_price'] = max_price or available_max_price
values['available_min_price'] = tools.float_round(available_min_price, 2)
values['available_max_price'] = tools.float_round(available_max_price, 2)
if category:
values['main_object'] = category
return request.render("website_sale.products", values)
@@ -325,10 +370,12 @@ class WebsiteSale(http.Controller):
category = ProductCategory.browse(int(category)).exists()
attrib_list = request.httprequest.args.getlist('attrib')
min_price = request.params.get('min_price')
max_price = request.params.get('max_price')
attrib_values = [[int(x) for x in v.split("-")] for v in attrib_list if v]
attrib_set = {v[1] for v in attrib_values}
keep = QueryURL('/shop', category=category and category.id, search=search, attrib=attrib_list)
keep = QueryURL('/shop', category=category and category.id, search=search, attrib=attrib_list, min_price=min_price, max_price=max_price)
categs = ProductCategory.search([('parent_id', '=', False)])
@@ -359,9 +406,32 @@ class WebsiteSale(http.Controller):
def pricelist_change(self, pl_id, **post):
if (pl_id.selectable or pl_id == request.env.user.partner_id.property_product_pricelist) \
and request.website.is_pricelist_available(pl_id.id):
redirect_url = request.httprequest.referrer
if redirect_url and request.website.is_view_active('website_sale.filter_products_price'):
decoded_url = url_parse(redirect_url)
args = url_decode(decoded_url.query)
min_price = args.get('min_price')
max_price = args.get('max_price')
if min_price or max_price:
previous_price_list = request.website.get_current_pricelist()
try:
min_price = float(min_price)
args['min_price'] = min_price and str(
previous_price_list.currency_id._convert(min_price, pl_id.currency_id, request.website.company_id, fields.Date.today(), round=False)
)
except (ValueError, TypeError):
pass
try:
max_price = float(max_price)
args['max_price'] = max_price and str(
previous_price_list.currency_id._convert(max_price, pl_id.currency_id, request.website.company_id, fields.Date.today(), round=False)
)
except (ValueError, TypeError):
pass
redirect_url = decoded_url.replace(query=url_encode(args)).to_url()
request.session['website_sale_current_pl'] = pl_id.id
request.website.sale_get_order(force_pricelist=pl_id.id)
return request.redirect(request.httprequest.referrer or '/shop')
return request.redirect(redirect_url or '/shop')
@http.route(['/shop/pricelist'], type='http', auth="public", website=True, sitemap=False)
def pricelist(self, promo, **post):
@@ -962,3 +962,38 @@ publicWidget.registry.websiteSaleProductPageReviews = publicWidget.Widget.extend
},
});
});
odoo.define('website_sale.price_range_option', function (require) {
'use strict';
const publicWidget = require('web.public.widget');
publicWidget.registry.multirangePriceSelector = publicWidget.Widget.extend({
selector: '#o_wsale_price_range_option',
events: {
'newRangeValue input[type="range"]': '_onPriceRangeSelected',
},
//----------------------------------------------------------------------
// Handlers
//----------------------------------------------------------------------
/**
* @private
* @param {Event} ev
*/
_onPriceRangeSelected(ev) {
const range = ev.currentTarget;
const search = $.deparam(window.location.search.substring(1));
delete search.min_price;
delete search.max_price;
if (parseFloat(range.min) !== range.valueLow) {
search['min_price'] = range.valueLow;
}
if (parseFloat(range.max) !== range.valueHigh) {
search['max_price'] = range.valueHigh;
}
window.location.search = $.param(search);
},
});
});
+16
View File
@@ -541,6 +541,22 @@
</xpath>
</template>
<template id="filter_products_price" inherit_id="website_sale.products" active="False" customize_show="True" name="Filter by Prices">
<xpath expr="//div[@id='products_grid_before']" position="before">
<t t-set="enable_left_column" t-value="True"/>
</xpath>
<xpath expr="//div[@id='products_grid_before']" position="inside">
<div t-if="available_min_price != available_max_price" id="o_wsale_price_range_option">
<label>Price</label>
<input type="range" multiple="multiple" class="custom-range range-with-input"
t-att-data-currency="pricelist.currency_id.symbol"
t-att-data-currency-position="pricelist.currency_id.position"
t-att-step="pricelist.currency_id.rounding" t-att-min="'%f' % (available_min_price)"
t-att-max="'%f' % (available_max_price)" t-att-value="'%f,%f' % (min_price, max_price)"/>
</div>
</xpath>
</template>
<template id="products_list_view" inherit_id="website_sale.products" active="False" customize_show="True" name="List View (by default)">
<xpath expr="//div[@id='products_grid']" position="after">
<!-- Nothing to do, this view is only meant to allow the server -->