From ce1da231b9c84d1b4e06b265ee7b1a6d89bf51ea Mon Sep 17 00:00:00 2001 From: Andrea Ulliana Date: Wed, 6 Mar 2019 09:37:23 +0000 Subject: [PATCH] [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) Co-authored-by: qsm-odoo --- .../snippets_thumbs/s_products_searchbar.png | Bin 0 -> 295 bytes addons/website/views/snippets.xml | 2 + addons/website_sale/controllers/main.py | 98 +++++++++++-- .../static/src/js/website_sale.editor.js | 88 +++++++++++- .../static/src/js/website_sale.js | 133 +++++++++++++++++- .../static/src/xml/website_sale.editor.xml | 52 +++++++ .../static/src/xml/website_sale_utils.xml | 40 ++++++ addons/website_sale/views/snippets.xml | 52 ++++++- addons/website_sale/views/templates.xml | 28 +++- 9 files changed, 466 insertions(+), 27 deletions(-) create mode 100644 addons/website/static/src/img/snippets_thumbs/s_products_searchbar.png create mode 100644 addons/website_sale/static/src/xml/website_sale.editor.xml create mode 100644 addons/website_sale/static/src/xml/website_sale_utils.xml diff --git a/addons/website/static/src/img/snippets_thumbs/s_products_searchbar.png b/addons/website/static/src/img/snippets_thumbs/s_products_searchbar.png new file mode 100644 index 0000000000000000000000000000000000000000..4f24bb529cfa24f478cff370e56b60ec7fe9d496 GIT binary patch literal 295 zcmeAS@N?(olHy`uVBq!ia0vp^DL@>+!2~1|vfXz8Db50q$YKTtZeb8+WSBKa0w~B{ z;_2(k{+Nl8L&U7G{>fjU&@oRJ$B>F!Z*TABZBP(l4KUSvXl2!W?ts`a-5CdE=kQuv z+qVYxJl`fVVe;w4i+nUii*!YqA~qx(Y+~hR)B!VE8N|R0CkAdXL$QGs%n&%x^tJ5O z + + diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index df264792c22..d39145da32c 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -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 diff --git a/addons/website_sale/static/src/js/website_sale.editor.js b/addons/website_sale/static/src/js/website_sale.editor.js index 8c6cd07d3ea..943b695daa4 100644 --- a/addons/website_sale/static/src/js/website_sale.editor.js +++ b/addons/website_sale/static/src/js/website_sale.editor.js @@ -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(); + }, +}); }); diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js index 5ec92758b62..317dc513d5c 100644 --- a/addons/website_sale/static/src/js/website_sale.js +++ b/addons/website_sale/static/src/js/website_sale.js @@ -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; + } + }, +}); }); diff --git a/addons/website_sale/static/src/xml/website_sale.editor.xml b/addons/website_sale/static/src/xml/website_sale.editor.xml new file mode 100644 index 00000000000..4cc12eafb1a --- /dev/null +++ b/addons/website_sale/static/src/xml/website_sale.editor.xml @@ -0,0 +1,52 @@ + + + + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ +
diff --git a/addons/website_sale/static/src/xml/website_sale_utils.xml b/addons/website_sale/static/src/xml/website_sale_utils.xml new file mode 100644 index 00000000000..0c470a0d58d --- /dev/null +++ b/addons/website_sale/static/src/xml/website_sale_utils.xml @@ -0,0 +1,40 @@ + + + + + + + diff --git a/addons/website_sale/views/snippets.xml b/addons/website_sale/views/snippets.xml index d0e8f3cdc50..0168a0a3652 100644 --- a/addons/website_sale/views/snippets.xml +++ b/addons/website_sale/views/snippets.xml @@ -1,8 +1,45 @@ - + + + -