[IMP] website_sale, *: add products searchbar snippet

* website

When the e-commerce is installed, you can now add a new snippet that can
be dropped anywhere: a search bar to search for products with an
autocomplete feature.
That snippet is also used as default "/shop" search bar. Finally,
you have the possibility to customize the snippet via snippet options.

task-1866973

closes odoo/odoo#30366

Signed-off-by: Quentin Smetz (qsm) <qsm@odoo.com>


Co-authored-by: qsm-odoo <qsm@odoo.com>
This commit is contained in:
Andrea Ulliana
2019-03-29 16:33:15 +00:00
committed by qsm-odoo
co-authored by qsm-odoo
parent c26eb41f9e
commit ce1da231b9
9 changed files with 466 additions and 27 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

+2
View File
@@ -893,11 +893,13 @@
<template id="external_snippets" inherit_id="website.snippets" priority="8">
<xpath expr="//div[@id='snippet_feature']//t[@t-snippet][last()]" position="after">
<t t-install="website_twitter" string="Twitter Scroller" t-thumbnail="/website/static/src/img/snippets_thumbs/s_twitter_scroll.png"/>
<t id="s_products_searchbar" t-install="website_sale" string="Products Search" t-thumbnail="/website/static/src/img/snippets_thumbs/s_products_searchbar.png"/>
</xpath>
<xpath expr="//div[@id='snippet_content']//t[@t-snippet][last()]" position="after">
<t t-install="website_event" string="Local Events" t-thumbnail="/website/static/src/img/snippets_thumbs/s_local_events.png"/>
<t t-install="website_mail_channel" string="Discussion Group" t-thumbnail="/website/static/src/img/snippets_thumbs/s_button_channel_subscribe.png"/>
<t t-install="mass_mailing" string="Newsletter" t-thumbnail="/website/static/src/img/snippets_thumbs/s_newsletter_subscribe_form.png"/>
<t id="s_products_searchbar_input" t-install="website_sale" string="Products Search Input" t-thumbnail="/website/static/src/img/snippets_thumbs/s_products_searchbar.png"/>
</xpath>
</template>
+88 -10
View File
@@ -157,18 +157,24 @@ class WebsiteSale(http.Controller):
def _get_search_order(self, post):
# OrderBy will be parsed in orm and so no direct sql injection
# id is added to be sure that order is a unique sort key
return 'is_published desc,%s , id desc' % post.get('order', 'website_sequence desc')
order = post.get('order') or 'website_sequence desc'
return 'is_published desc, %s, id desc' % order
def _get_search_domain(self, search, category, attrib_values):
domain = request.website.sale_product_domain()
def _get_search_domain(self, search, category, attrib_values, search_in_description=True):
domains = [request.website.sale_product_domain()]
if search:
for srch in search.split(" "):
domain += [
'|', '|', '|', ('name', 'ilike', srch), ('description', 'ilike', srch),
('description_sale', 'ilike', srch), ('product_variant_ids.default_code', 'ilike', srch)]
subdomains = [
[('name', 'ilike', srch)],
[('product_variant_ids.default_code', 'ilike', srch)]
]
if search_in_description:
subdomains.append([('description', 'ilike', srch)])
subdomains.append([('description_sale', 'ilike', srch)])
domains.append(expression.OR(subdomains))
if category:
domain += [('public_categ_ids', 'child_of', int(category))]
domains.append([('public_categ_ids', 'child_of', int(category))])
if attrib_values:
attrib = None
@@ -180,13 +186,13 @@ class WebsiteSale(http.Controller):
elif value[0] == attrib:
ids.append(value[1])
else:
domain += [('attribute_line_ids.value_ids', 'in', ids)]
domains.append([('attribute_line_ids.value_ids', 'in', ids)])
attrib = value[0]
ids = [value[1]]
if attrib:
domain += [('attribute_line_ids.value_ids', 'in', ids)]
domains.append([('attribute_line_ids.value_ids', 'in', ids)])
return domain
return expression.AND(domains)
@http.route([
'''/shop''',
@@ -1114,3 +1120,75 @@ class WebsiteSale(http.Controller):
lang = request.env['res.lang']._lang_get(request.env.context.get('lang') or 'en_US')
return lang.format(fmt, currency.round(amount), grouping=True, monetary=True)\
.replace(r' ', u'\N{NO-BREAK SPACE}').replace(r'-', u'-\N{ZERO WIDTH NO-BREAK SPACE}')
# --------------------------------------------------------------------------
# Products Search Bar
# --------------------------------------------------------------------------
@http.route('/shop/products/autocomplete', type='json', auth='public', website=True)
def products_autocomplete(self, term, options={}, **kwargs):
"""
Returns list of products according to the term and product options
Params:
term (str): search term written by the user
options (dict)
- 'limit' (int), default to 5: number of products to consider
- 'display_description' (bool), default to True
- 'display_price' (bool), default to True
- 'order' (str)
- 'max_nb_chars' (int): max number of characters for the
description if returned
Returns:
dict (or False if no result)
- 'products' (list): products (only their needed field values)
note: the prices will be strings properly formatted and
already containing the currency
- 'products_count' (int): the number of products in the database
that matched the search query
"""
ProductTemplate = request.env['product.template']
display_description = options.get('display_description', True)
display_price = options.get('display_price', True)
order = self._get_search_order(options)
max_nb_chars = options.get('max_nb_chars', 999)
category = options.get('category')
attrib_values = options.get('attrib_values')
domain = self._get_search_domain(term, category, attrib_values, display_description)
products = ProductTemplate.search(
domain,
limit=min(20, options.get('limit', 5)),
order=order
)
fields = ['id', 'name', 'website_url']
if display_description:
fields.append('description_sale')
res = {
'products': products.read(fields),
'products_count': ProductTemplate.search_count(domain),
}
if display_description:
for res_product in res['products']:
desc = res_product['description_sale']
if desc and len(desc) > max_nb_chars:
res_product['description_sale'] = "%s..." % desc[:(max_nb_chars - 3)]
if display_price:
FieldMonetary = request.env['ir.qweb.field.monetary']
monetary_options = {
'display_currency': request.website.get_current_pricelist().currency_id,
}
for res_product, product in zip(res['products'], products):
combination_info = product._get_combination_info(only_template=True)
res_product.update(combination_info)
res_product['list_price'] = FieldMonetary.value_to_html(res_product['list_price'], monetary_options)
res_product['price'] = FieldMonetary.value_to_html(res_product['price'], monetary_options)
return res
@@ -52,15 +52,31 @@ WebsiteNewMenu.include({
odoo.define('website_sale.editor', function (require) {
'use strict';
require('web.dom_ready');
var core = require('web.core');
var Dialog = require('web.Dialog');
var options = require('web_editor.snippets.options');
var publicWidget = require('web.public.widget');
if (!$('.js_sale').length) {
return Promise.reject("DOM doesn't contain '.js_sale'");
}
var _t = core._t;
var qweb = core.qweb;
$('.oe_website_sale').on('click', '.oe_currency_value:o_editable', function (ev) {
$(ev.currentTarget).selectContent();
publicWidget.registry.websiteSaleCurrency = publicWidget.Widget.extend({
selector: '.oe_website_sale',
disabledInEditableMode: false,
edit_events: {
'click .oe_currency_value:o_editable': '_onCurrencyValueClick',
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onCurrencyValueClick: function (ev) {
$(ev.currentTarget).selectContent();
},
});
options.registry.website_sale = options.Class.extend({
@@ -160,4 +176,64 @@ options.registry.website_sale = options.Class.extend({
}).then(this.reload);
}
});
/**
* Handles the edition of products search bar snippet.
*/
options.registry.ProductsSearchBar = options.Class.extend({
xmlDependencies: ['/website_sale/static/src/xml/website_sale.editor.xml'],
/**
* @override
*/
start: function () {
this.$searchProductsInput = this.$('.search-query');
this.$searchOrderField = this.$('.o_wsale_search_order_by');
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Options
//--------------------------------------------------------------------------
/**
* @see this.selectClass for parameters
*/
openSearchbarSettings: function (previewMode, value, $opt) {
var self = this;
new Dialog(this, {
title: _t("Products Search Bar"),
$content: $(qweb.render('website_sale.dialog.productsSearchBar', {
currentOrderBy: this.$searchOrderField.val(),
currentLimit: parseInt(this.$searchProductsInput.attr('data-limit')),
currentDisplayDescription: this.$searchProductsInput.attr('data-display-description') === 'true',
currentDisplayPrice: this.$searchProductsInput.attr('data-display-price') === 'true',
currentDisplayImage: this.$searchProductsInput.attr('data-display-image') === 'true',
})),
buttons: [
{
text: _t("Save"),
classes: 'btn-primary',
click: function () {
self.$searchOrderField.attr({
'value': this.$('#order_by').val(),
});
self.$searchProductsInput.attr({
'data-limit': this.$('#use_autocomplete').is(':checked') ? this.$('#limit').val() : 0,
'data-display-description': this.$('#display_description').is(':checked'),
'data-display-price': this.$('#display_price').is(':checked'),
'data-display-image': this.$('#display_image').is(':checked'),
});
self.$target.trigger('content_changed');
this.close();
},
},
{
text: _t("Discard"),
close: true,
},
],
}).open();
},
});
});
@@ -120,10 +120,13 @@ odoo.define('website_sale.website_sale', function (require) {
var core = require('web.core');
var config = require('web.config');
var concurrency = require('web.concurrency');
var publicWidget = require('web.public.widget');
var VariantMixin = require('sale.VariantMixin');
require("website.content.zoomodoo");
var qweb = core.qweb;
publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, {
selector: '.oe_website_sale',
events: _.extend({}, VariantMixin.events || {}, {
@@ -139,7 +142,7 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, {
'touchend form.js_add_cart_json label': '_onMouseupAddCartLabel',
'change .css_attribute_color input': '_onChangeColorAttribute',
'click .show_coupon': '_onClickShowCoupon',
'submit .o_website_sale_search': '_onSubmitSaleSearch',
'submit .o_wsale_products_searchbar_form': '_onSubmitSaleSearch',
'change select[name="country_id"]': '_onChangeCountry',
'change #shipping_use_same': '_onChangeShippingUseSame',
'click .toggle_summary': '_onToggleSummary',
@@ -733,4 +736,132 @@ publicWidget.registry.websiteSaleCart = publicWidget.Widget.extend({
$(ev.currentTarget).closest('tr').find('.js_quantity').val(0).trigger('change');
},
});
/**
* @todo maybe the custom autocomplete logic could be extract to be reusable
*/
publicWidget.registry.productsSearchBar = publicWidget.Widget.extend({
selector: '.o_wsale_products_searchbar_form',
xmlDependencies: ['/website_sale/static/src/xml/website_sale_utils.xml'],
events: {
'input .search-query': '_onInput',
'focusout': '_onFocusOut',
'keydown .search-query': '_onKeydown',
},
autocompleteMinWidth: 300,
/**
* @constructor
*/
init: function () {
this._super.apply(this, arguments);
this._dp = new concurrency.DropPrevious();
this._onInput = _.debounce(this._onInput, 400);
this._onFocusOut = _.debounce(this._onFocusOut, 100);
},
/**
* @override
*/
start: function () {
this.$input = this.$('.search-query');
this.order = this.$('.o_wsale_search_order_by').val();
this.limit = parseInt(this.$input.data('limit'));
this.displayDescription = !!this.$input.data('displayDescription');
this.displayPrice = !!this.$input.data('displayPrice');
this.displayImage = !!this.$input.data('displayImage');
if (this.limit) {
this.$input.attr('autocomplete', 'off');
}
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_fetch: function () {
return this._rpc({
route: '/shop/products/autocomplete',
params: {
'term': this.$input.val(),
'options': {
'order': this.order,
'limit': this.limit,
'display_description': this.displayDescription,
'display_price': this.displayPrice,
'max_nb_chars': Math.round(Math.max(this.autocompleteMinWidth, parseInt(this.$el.width())) * 0.22),
},
},
});
},
/**
* @private
*/
_render: function (res) {
var $prevMenu = this.$menu;
this.$el.toggleClass('dropdown show', !!res);
if (res) {
var products = res['products'];
this.$menu = $(qweb.render('website_sale.productsSearchBar.autocomplete', {
products: products,
hasMoreProducts: products.length < res['products_count'],
currency: res['currency'],
widget: this,
}));
this.$menu.css('min-width', this.autocompleteMinWidth);
this.$el.append(this.$menu);
}
if ($prevMenu) {
$prevMenu.remove();
}
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onInput: function () {
if (!this.limit) {
return;
}
this._dp.add(this._fetch()).then(this._render.bind(this));
},
/**
* @private
*/
_onFocusOut: function () {
if (!this.$el.has(document.activeElement).length) {
this._render();
}
},
/**
* @private
*/
_onKeydown: function (ev) {
switch (ev.which) {
case $.ui.keyCode.ESCAPE:
this._render();
break;
case $.ui.keyCode.UP:
ev.preventDefault();
this.$menu.children().last().focus();
break;
case $.ui.keyCode.DOWN:
ev.preventDefault();
this.$menu.children().first().focus();
break;
}
},
});
});
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!-- Products Search Bar settings dialog -->
<div t-name="website_sale.dialog.productsSearchBar">
<div class="form-group">
<label for="order_by">Search filter to use <b>by default</b></label>
<select class="custom-select" id="order_by">
<option value="" t-att-selected="!currentOrderBy ? 'selected' : undefined">Default order</option>
<option value="name asc" t-att-selected="currentOrderBy == 'name asc' ? 'selected' : undefined">Order by name (A-Z)</option>
<option value="name desc" t-att-selected="currentOrderBy == 'name desc' ? 'selected' : undefined">Order by name (Z-A)</option>
<option value="list_price asc" t-att-selected="currentOrderBy == 'list_price asc' ? 'selected' : undefined">Order by price: low to high</option>
<option value="list_price desc" t-att-selected="currentOrderBy == 'list_price desc' ? 'selected' : undefined">Order by price: high to low</option>
</select>
</div>
<div class="form-group">
<div class="custom-control custom-switch" data-toggle="collapse" data-target="#use_autocomplete_options">
<input type="checkbox" class="custom-control-input" id="use_autocomplete" t-att-checked="currentLimit > 0 ? 'checked' : undefined"/>
<label class="custom-control-label" for="use_autocomplete">Add product suggestions</label>
</div>
</div>
<div id="use_autocomplete_options" t-attf-class="collapse #{currentLimit > 0 ? 'show' : ''}">
<div class="form-group">
<label for="limit">Maximum number of products to display</label>
<select class="custom-select" id="limit">
<option t-foreach="20" t-as="limit"
t-esc="limit + 1" t-att-value="limit + 1"
t-att-selected="currentLimit == (limit + 1) ? 'selected' : undefined"/>
</select>
</div>
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="display_description" t-att-checked="currentDisplayDescription ? 'checked' : undefined"/>
<label class="custom-control-label" for="display_description">Display product <b>description</b> and allow to search in</label>
</div>
</div>
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="display_price" t-att-checked="currentDisplayPrice ? 'checked' : undefined"/>
<label class="custom-control-label" for="display_price">Display product <b>price</b></label>
</div>
</div>
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="display_image" t-att-checked="currentDisplayImage ? 'checked' : undefined"/>
<label class="custom-control-label" for="display_image">Display product <b>image</b></label>
</div>
</div>
</div>
</div>
</templates>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!-- Products Search Bar autocomplete item -->
<div t-name="website_sale.productsSearchBar.autocomplete"
class="dropdown-menu show w-100">
<t t-if="!products.length">
<span class="dropdown-item-text text-muted">No results found. Please try another search.</span>
</t>
<a t-foreach="products" t-as="product"
t-att-href="product['website_url']" class="dropdown-item p-2 text-wrap">
<div class="media align-items-center o_search_product_item">
<t t-if="widget.displayImage">
<img t-attf-src="/web/image/product.template/#{product['id']}/image_small"
class="flex-shrink-0"/>
</t>
<div class="media-body px-3">
<t t-set="description" t-value="widget.displayDescription and product['description_sale']"/>
<h6 t-attf-class="font-weight-bold #{description ? '' : 'mb-0'}" t-esc="product['name']"/>
<p t-if="description" class="mb-0" t-esc="description"/>
</div>
<div t-if="widget.displayPrice" class="flex-shrink-0">
<t t-if="product['has_discounted_price']">
<span class="text-danger text-nowrap" style="text-decoration: line-through;">
<t t-raw="product['list_price']"/>
</span>
<br/>
</t>
<b class="text-nowrap">
<t t-raw="product['price']"/>
</b>
</div>
</div>
</a>
<t t-if="hasMoreProducts">
<button type="submit" class="dropdown-item text-center text-primary">All results</button>
</t>
</div>
</templates>
+49 -3
View File
@@ -1,8 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="website_sale" inherit_id="website.snippets" name="Snippet Ecommerce">
<xpath expr="//div[@id='snippet_options']" position="inside">
<template id="s_products_searchbar_input" name="Products Search Input">
<t t-call="website_sale.website_sale_products_search_box">
<t t-set="_classes" t-value="'s_wsale_products_searchbar_input mb-3'"/>
</t>
</template>
<template id="s_products_searchbar" name="Products Search">
<section class="s_wsale_products_searchbar bg-200 pt24 pb24">
<div class="container">
<div class="row">
<div class="col-lg-8 offset-lg-2">
<div class="card">
<div class="card-body">
<h2>Search for a product</h2>
<p>We have amazing products in our shop, check them now !</p>
<t t-call="website_sale.s_products_searchbar_input"/>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<template id="snippets" inherit_id="website.snippets" name="e-commerce snippets">
<xpath expr="//div[@id='snippet_feature']//t[@t-snippet][last()]" position="after">
<t t-snippet="website_sale.s_products_searchbar" t-thumbnail="/website/static/src/img/snippets_thumbs/s_products_searchbar.png"/>
</xpath>
<xpath expr="//div[@id='snippet_content']//t[@t-snippet][last()]" position="after">
<t t-snippet="website_sale.s_products_searchbar_input" t-thumbnail="/website/static/src/img/snippets_thumbs/s_products_searchbar.png"/>
</xpath>
</template>
<template id="remove_external_snippets" inherit_id="website.external_snippets">
<xpath expr="//t[@t-install='website_sale'][@id='s_products_searchbar']" position="replace"/>
<xpath expr="//t[@t-install='website_sale'][@id='s_products_searchbar_input']" position="replace"/>
</template>
<template id="snippet_options" inherit_id="website.snippet_options" name="e-commerce snippet options">
<xpath expr="." position="inside">
<div data-js='website_sale'
data-selector=".oe_product.oe_grid"
data-no-check="true">
@@ -31,7 +68,16 @@
</div>
</div>
</div>
</xpath>
<div data-js="ProductsSearchBar"
data-selector=".s_wsale_products_searchbar_input">
<a tabindex="-1" href="#" class="dropdown-item" data-no-preview="true" data-open-searchbar-settings=""><i class="fa fa-search"></i>Products Search Bar</a>
</div>
</xpath>
<xpath expr="//div[@data-js='content']" position="attributes">
<attribute name="data-selector" add=".s_wsale_products_searchbar_input" separator=","/>
<attribute name="data-drop-near" add=".s_wsale_products_searchbar_input" separator=","/>
</xpath>
</template>
</odoo>
+21 -7
View File
@@ -65,15 +65,32 @@
</xpath>
</template>
<!-- Products Search Bar input-group template -->
<template id="website_sale_products_search_box" inherit_id="website.website_search_box" primary="True">
<xpath expr="//input[@name='search']" position="attributes">
<attribute name="data-limit">5</attribute>
<attribute name="data-display-description">true</attribute>
<attribute name="data-display-price">true</attribute>
<attribute name="data-display-image">true</attribute>
</xpath>
<xpath expr="//div[hasclass('input-group')]" position="replace">
<form t-attf-class="o_wsale_products_searchbar_form #{_classes}" t-att-action="action if action else '/shop'" method="get">
<t>$0</t>
<input name="order" type="hidden" class="o_wsale_search_order_by" value=""/>
<t t-raw="0"/>
</form>
</xpath>
</template>
<template id="search" name="Search Box">
<form t-att-action="keep('/shop'+ ('/category/'+slug(category)) if category else None, search=0)" method="get" t-att-class="_classes">
<t t-call="website_sale.website_sale_products_search_box">
<t t-set="action" t-value="keep('/shop'+ ('/category/'+slug(category)) if category else None, search=0)"/>
<t t-if="attrib_values">
<t t-foreach="attrib_values" t-as="a">
<input type="hidden" name="attrib" t-att-value="'%s-%s' % (a[0], a[1])" />
</t>
</t>
<t t-call="website.website_search_box" />
</form>
</t>
</template>
<template id="search count" inherit_id="website.website_search_box" active="False" customize_show="True" name="Show # found">
@@ -189,9 +206,7 @@
<div class="oe_structure" id="oe_structure_website_sale_products_1"/>
<div class="container py-2 oe_website_sale">
<div class="products_pager form-inline justify-content-center">
<t t-call="website_sale.search">
<t t-set="_classes">o_website_sale_search</t>
</t>
<t t-call="website_sale.search"/>
<t t-call="website_sale.pricelist_list">
<t t-set="_classes">ml-2</t>
</t>
@@ -1694,5 +1709,4 @@
</div>
</div>
</template>
</odoo>