From df2ea31ff6eb5978c700d0ca0d096c4b1b33bee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Warnon?= Date: Fri, 7 Sep 2018 14:57:13 +0200 Subject: [PATCH] [IMP] (website_)sale: Add custom values for product attributes Purpose ======= Product attrivutes can now have custom values. These values are specified by the user during the SO creation. They are also available on the webshop frontend. Specification ============= add a new mode to generate variants as they are added to the order/cart - ability to define attribute extra prices & exclude combinations --- addons/product/models/product_attribute.py | 20 +- .../sale/controllers/product_configurator.py | 12 +- addons/sale/data/sale_demo.xml | 28 +- addons/sale/models/product_product.py | 15 +- addons/sale/models/sale.py | 14 +- addons/sale/security/ir.model.access.csv | 1 + .../static/src/js/list_editable_renderer.js | 71 +++ .../static/src/js/product_configurator.js | 168 ------- .../src/js/product_configurator_controller.js | 137 ++++++ ...uct_configurator_list_editable_renderer.js | 71 --- .../src/js/product_configurator_mixin.js | 437 ++++++++++++++++++ .../src/js/product_configurator_modal.js | 297 +++++++----- .../src/js/product_configurator_renderer.js | 52 +++ .../src/js/product_configurator_utils.js | 272 ----------- .../src/js/product_configurator_view.js | 20 + .../static/src/scss/product_configurator.scss | 16 + .../static/tests/product_configurator.test.js | 48 +- addons/sale/views/assets.xml | 8 +- addons/sale/views/product_attribute_views.xml | 3 +- .../sale_product_configurator_templates.xml | 50 +- addons/sale/views/sale_views.xml | 1 + .../tests/product_configurator_test_ui.js | 135 +++--- .../views/sale_order_views.xml | 9 + addons/website_sale/controllers/main.py | 17 +- addons/website_sale/models/sale_order.py | 17 +- .../website_sale/security/ir.model.access.csv | 1 + .../static/src/js/website_sale.js | 32 +- .../static/src/js/website_sale_options.js | 218 +++++---- .../static/src/js/website_sale_tour_buy.js | 2 +- ...e_sale_tour_shop_custom_attribute_value.js | 63 +++ addons/website_sale/views/templates.xml | 3 +- 31 files changed, 1390 insertions(+), 848 deletions(-) create mode 100644 addons/sale/static/src/js/list_editable_renderer.js delete mode 100644 addons/sale/static/src/js/product_configurator.js create mode 100644 addons/sale/static/src/js/product_configurator_controller.js delete mode 100644 addons/sale/static/src/js/product_configurator_list_editable_renderer.js create mode 100644 addons/sale/static/src/js/product_configurator_mixin.js create mode 100644 addons/sale/static/src/js/product_configurator_renderer.js delete mode 100644 addons/sale/static/src/js/product_configurator_utils.js create mode 100644 addons/sale/static/src/js/product_configurator_view.js create mode 100644 addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js diff --git a/addons/product/models/product_attribute.py b/addons/product/models/product_attribute.py index 0a348370199..7efc0968c55 100644 --- a/addons/product/models/product_attribute.py +++ b/addons/product/models/product_attribute.py @@ -52,19 +52,20 @@ class ProductAttributeValue(models.Model): class ProductProductAttributeValue(models.Model): _name = "product.product.attribute.value" + _order = 'sequence, attribute_id, id' name = fields.Char('Value', related="product_attribute_value_id.name") product_attribute_value_id = fields.Many2one('product.attribute.value', string='Attribute Value', required=True, ondelete='cascade', index=True) product_tmpl_id = fields.Many2one('product.template', string='Product Template', required=True, ondelete='cascade', index=True) attribute_id = fields.Many2one('product.attribute', string='Attribute', related="product_attribute_value_id.attribute_id") - sequence = fields.Integer('product.sequence', related="product_attribute_value_id.sequence") + sequence = fields.Integer('Sequence', related="product_attribute_value_id.sequence") price_extra = fields.Float( string='Attribute Price Extra', default=0.0, digits=dp.get_precision('Product Price'), help="Price Extra: Extra price for the variant with this attribute value on sale price. eg. 200 price extra, 1000 + 200 = 1200.") exclude_for = fields.Many2many( 'product.attribute.filter.line', string="Exclude for", relation="product_attribute_value_exclusion", - help="""A list of product and attribute values that you want to exclude for this product's attribue value. - Also applies on optionnal and accessory products.""") + help="""A list of product and attribute values that you want to exclude for this product's attribute value. + Also applies on optional and accessory products.""") @api.multi def name_get(self): @@ -90,7 +91,12 @@ class ProductAttributeLine(models.Model): product_tmpl_id = fields.Many2one('product.template', string='Product Template', ondelete='cascade', required=True) attribute_id = fields.Many2one('product.attribute', string='Attribute', ondelete='restrict', required=True) value_ids = fields.Many2many('product.attribute.value', string='Attribute Values') - product_value_ids = fields.Many2many('product.product.attribute.value', string='Product Attribute Values', compute="_set_product_value_ids") + product_value_ids = fields.Many2many( + 'product.product.attribute.value', + string='Product Attribute Values', + compute="_set_product_value_ids", + store=False + ) @api.constrains('value_ids', 'attribute_id') def _check_valid_attribute(self): @@ -114,13 +120,14 @@ class ProductAttributeLine(models.Model): for product_attribute_line in self: product_attribute_line.product_value_ids = self.env['product.product.attribute.value'].search([ ('product_tmpl_id', 'in', product_attribute_line.product_tmpl_id.ids), - ('product_attribute_value_id.attribute_id', 'in', product_attribute_line.value_ids.mapped('attribute_id').ids)]) + ('product_attribute_value_id.attribute_id', 'in', product_attribute_line.value_ids.mapped('attribute_id').ids)] + ).sorted(lambda product_product_attribute: product_product_attribute.sequence) @api.multi def unlink(self): for product_attribute_line in self: self.env['product.product.attribute.value'].search([ - ('product_tmpl_id', 'in', product_attribute_line.product_tmpl_id.ids), + ('product_tmpl_id', 'in', product_attribute_line.product_tmpl_id.ids), ('product_attribute_value_id.attribute_id', 'in', product_attribute_line.value_ids.mapped('attribute_id').ids)]).unlink() return super(ProductAttributeLine, self).unlink() @@ -152,6 +159,7 @@ class ProductAttributeLine(models.Model): self.env['product.product.attribute.value'].create({ 'product_attribute_value_id': product_attribute_value.id, 'product_tmpl_id': attribute_line.product_tmpl_id.id}) + # at this point, existing properties can be removed to reflect the modifications on value_ids if product_product_attribute_values_to_remove: product_product_attribute_values_to_remove.unlink() diff --git a/addons/sale/controllers/product_configurator.py b/addons/sale/controllers/product_configurator.py index 19145abbefa..a98df660e93 100644 --- a/addons/sale/controllers/product_configurator.py +++ b/addons/sale/controllers/product_configurator.py @@ -36,13 +36,17 @@ class ProductConfiguratorController(http.Controller): def _optional_product_items(self, product_id, pricelist, **kw): product = request.env['product.product'].with_context(self._get_product_context(pricelist, **kw)).browse(int(product_id)) + to_currency = product.currency_id + if pricelist: + to_currency = pricelist.currency_id return request.env['ir.ui.view'].render_template("sale.optional_product_items", { 'product': product, 'reference_product': product, 'pricelist': pricelist, + 'to_currency': to_currency, 'get_attribute_value_ids': self._get_attribute_value_ids, - 'main_product_attr_ids': self._get_main_product_attr_ids(product), + 'main_product_attr_ids': self._get_main_product_attr_ids(product, pricelist=pricelist), }) def _show_optional_products(self, product_id, pricelist, handle_stock, **kw): @@ -74,7 +78,7 @@ class ProductConfiguratorController(http.Controller): 'to_currency': to_currency, 'handle_stock': handle_stock, 'get_attribute_value_ids': self._get_attribute_value_ids, - 'main_product_attr_ids': self._get_main_product_attr_ids(product), + 'main_product_attr_ids': self._get_main_product_attr_ids(product, pricelist=pricelist), }) def _get_attribute_value_ids(self, product, reference_product=None, pricelist=None): @@ -111,8 +115,8 @@ class ProductConfiguratorController(http.Controller): return attribute_value_ids - def _get_main_product_attr_ids(self, product): - main_product_attr_ids = self._get_attribute_value_ids(product.product_tmpl_id) + def _get_main_product_attr_ids(self, product, pricelist=None): + main_product_attr_ids = self._get_attribute_value_ids(product.product_tmpl_id, pricelist=pricelist) for variant in main_product_attr_ids: if variant[0] == product.id: # We indeed need a list of lists (even with only 1 element) diff --git a/addons/sale/data/sale_demo.xml b/addons/sale/data/sale_demo.xml index 496a58721af..ba7ddd95d2f 100644 --- a/addons/sale/data/sale_demo.xml +++ b/addons/sale/data/sale_demo.xml @@ -729,7 +729,29 @@ Thanks! - @@ -759,7 +781,7 @@ Thanks! - diff --git a/addons/sale/models/product_product.py b/addons/sale/models/product_product.py index 02096def606..0e2029780cc 100644 --- a/addons/sale/models/product_product.py +++ b/addons/sale/models/product_product.py @@ -67,14 +67,25 @@ class ProductAttribute(models.Model): class ProductAttributeValue(models.Model): _inherit = "product.attribute.value" + is_custom = fields.Boolean('Is custom value', help="Allow users to input custom values for this attribute value") html_color = fields.Char( string='HTML Color Index', oldname='color', help="""Here you can set a specific HTML color index (e.g. #ff0000) to display the color if the - attibute type is 'Color'.""") + attribute type is 'Color'.""") class ProductProductAttributeValue(models.Model): _inherit = "product.product.attribute.value" - html_color = fields.Char('HTML Color Index', related="product_attribute_value_id.html_color") \ No newline at end of file + html_color = fields.Char('HTML Color Index', related="product_attribute_value_id.html_color") + is_custom = fields.Boolean('Is custom value', related="product_attribute_value_id.is_custom") + + +class ProductAttributeCustomValue(models.Model): + _name = "product.attribute.custom.value" + _rec_name = 'custom_value' + + attribute_value_id = fields.Many2one('product.attribute.value', string='Attribute') + sale_order_line_id = fields.Many2one('sale.order.line', string='Sale order line') + custom_value = fields.Char('Custom value') diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index df3cedc0cde..137fe5b669e 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -936,7 +936,9 @@ class SaleOrderLine(models.Model): res['product_id'] = int(self._context.get('product_id')) if self._context.get('quantity'): res['product_uom_qty'] = float(self._context.get('quantity')) - + if self._context.get('product_custom_variant_values'): + values = self._context.get('product_custom_variant_values') + res['product_custom_variant_values'] = [(0, 0, value) for value in values] return res @api.depends('product_uom_qty', 'discount', 'price_unit', 'tax_id') @@ -1126,6 +1128,7 @@ class SaleOrderLine(models.Model): product_updatable = fields.Boolean(compute='_compute_product_updatable', string='Can Edit Product', readonly=True, default=True) product_uom_qty = fields.Float(string='Ordered Quantity', digits=dp.get_precision('Product Unit of Measure'), required=True, default=1.0) product_uom = fields.Many2one('uom.uom', string='Unit of Measure') + product_custom_variant_values = fields.One2many('product.attribute.custom.value', 'sale_order_line_id', string='Custom variant values') # Non-stored related field to allow portal user to see the image of the product he has ordered product_image = fields.Binary('Product Image', related="product_id.image", store=False) @@ -1421,7 +1424,14 @@ class SaleOrderLine(models.Model): self.product_id = False return result - vals.update(name=self.get_sale_order_line_multiline_description_sale(product)) + name = self.get_sale_order_line_multiline_description_sale(product) + if self.product_custom_variant_values: + name += '\n' + for product_custom_variant_value in self.product_custom_variant_values: + if product_custom_variant_value.custom_value and product_custom_variant_value.custom_value.strip(): + name += '\n' + product_custom_variant_value.attribute_value_id.name + ': ' + product_custom_variant_value.custom_value.strip() + + vals.update(name=name) self._compute_tax_id() diff --git a/addons/sale/security/ir.model.access.csv b/addons/sale/security/ir.model.access.csv index 71eb9fa6e41..5f0e5140394 100644 --- a/addons/sale/security/ir.model.access.csv +++ b/addons/sale/security/ir.model.access.csv @@ -42,6 +42,7 @@ access_product_product_sale_manager,product.product salemanager,model_product_pr access_product_attribute_sale_manager,product.attribute manager,product.model_product_attribute,sales_team.group_sale_manager,1,1,1,1 access_product_attribute_value_sale_manager,product.attribute manager value,product.model_product_attribute_value,sales_team.group_sale_manager,1,1,1,1 access_product_product_attribute_sale_manager,product.product.attribute manager value,product.model_product_product_attribute_value,sales_team.group_sale_manager,1,1,1,1 +access_product_product_attribute_custom_value,product.attribute.custom value manager,model_product_attribute_custom_value,sales_team.group_sale_manager,1,1,1,1 access_product_attribute_filter_line_sale_manager,product.attribute manager filter line,product.model_product_attribute_filter_line,sales_team.group_sale_manager,1,1,1,1 access_product_attribute_line_sale_manager,product.attribute manager line,product.model_product_attribute_line,sales_team.group_sale_manager,1,1,1,1 access_account_tax_sale_manager,account.tax sale manager,account.model_account_tax,sales_team.group_sale_salesman,1,0,0,0 diff --git a/addons/sale/static/src/js/list_editable_renderer.js b/addons/sale/static/src/js/list_editable_renderer.js new file mode 100644 index 00000000000..8623b4eb708 --- /dev/null +++ b/addons/sale/static/src/js/list_editable_renderer.js @@ -0,0 +1,71 @@ +/** + * This adds the possibility to open the product configurator when the control + * "Configure a product" is clicked + */ +odoo.define('sale.ProductConfiguratorEditableListRenderer', function (require) { +"use strict"; + +var core = require('web.core'); +var _t = core._t; +var ListRenderer = require('web.ListRenderer'); + +ListRenderer.include({ + /** + * @override + */ + _onAddRecord: function (ev) { + // we don't want the browser to navigate to a the # url + ev.preventDefault(); + + // we don't want the click to cause other effects, such as unselecting + // the row that we are creating, because it counts as a click on a tr + ev.stopPropagation(); + + // but we do want to unselect current row + var self = this; + this.unselectRow().then(function () { + var open_product_configurator = false; + var context = ev.currentTarget.dataset.context; + + var context_json = false; + if(context){ + context = context.replace(/\'/g, '"'); + context_json = JSON.parse(context); + + open_product_configurator = context_json.open_product_configurator; + } + + if(open_product_configurator){ + self._rpc({ + model: 'ir.model.data', + method: 'xmlid_to_res_id', + kwargs: {xmlid: 'sale.sale_product_configurator_view_form'}, + }).then(function(res_id) { + self.do_action({ + name: _t('Configure a product'), + type: 'ir.actions.act_window', + res_model: 'sale.product.configurator', + views: [[res_id, 'form']], + target: 'new' + }, { + on_close: function (products) { + if(products && products !== 'special'){ + self.trigger_up('add_record', { + context: products, + forceEditable: "bottom" , + allowWarning: true, + onSuccess: function(){ + self.unselectRow(); + } + }); + } + } + }); + }); + } else { + self.trigger_up('add_record', {context: context && [context]}); // TODO write a test, the deferred was not considered + } + }); + } +}); +}); \ No newline at end of file diff --git a/addons/sale/static/src/js/product_configurator.js b/addons/sale/static/src/js/product_configurator.js deleted file mode 100644 index 0be903d31d3..00000000000 --- a/addons/sale/static/src/js/product_configurator.js +++ /dev/null @@ -1,168 +0,0 @@ -odoo.define('sale.ProductConfiguratorFormRenderer', function (require) { - "use strict"; - - var FormRenderer = require('web.FormRenderer'); - var ProductConfiguratorUtils = require('sale.product_configurator_utils'); - - var ProductConfiguratorFormRenderer = FormRenderer.extend({ - /** - * @override - */ - start: function () { - this._super.apply(this, arguments); - this.$el.append('
'); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @public - * Renders the product configurator within the form - */ - render_configurator: function (configurator) { - var $configurator_container = this.$el.find('.configurator_container'); - $configurator_container.empty(); - - var $configurator = $(configurator); - $configurator.appendTo($configurator_container); - - ProductConfiguratorUtils.addConfiguratorEvents($configurator_container, "", false, $('.js_sale_order_pricelist_id').html()); - - $('.js_add_cart_variants', $configurator_container).each(function () { - $('input.js_variant_change, select.js_variant_change', this).first().trigger('change'); - }); - } - }); - - return ProductConfiguratorFormRenderer; -}); - -odoo.define('sale.ProductConfiguratorFormController', function (require) { - "use strict"; - - var core = require('web.core'); - var _t = core._t; - var ajax = require('web.ajax'); - var FormController = require('web.FormController'); - var OptionalProductsModal = require('sale.OptionalProductsModal'); - - var ProductConfiguratorFormController = FormController.extend({ - custom_events: _.extend({}, FormController.prototype.custom_events, { - field_changed: '_onFieldChanged' - }), - className: 'o_product_configurator', - /** - * @override - * We need to override the default click behavior for our "Add" button - * because there is a possibility that this product has optional products. - * If so, we need to display an extra modal to choose the options - */ - _onButtonClicked: function (event) { - if(event.stopPropagation){ - event.stopPropagation(); - } - var attrs = event.data.attrs; - if (attrs.special === 'cancel') { - this._super.apply(this, arguments); - } else { - if(!this.$el.parents('.modal').find('.o_sale_product_configurator_add').hasClass('disabled')){ - this._handleAdd(this.$el); - } - } - }, - /** - * @override - * This is overriden to allow catching the "select" event on our product template select field. - * This will not work anymore if more fields are added to the form. - * TODO: Find a better way to catch that event. - */ - _onFieldChanged: function (event) { - var self = this; - - self.$el.parents('.modal').find('.o_sale_product_configurator_add').removeClass('disabled'); - - ajax.jsonRpc("/product_configurator/configure", 'call', { - 'product_id': event.data.changes.product_template_id.id, - 'pricelist_id': $('.js_sale_order_pricelist_id').html() - }).then(function (configurator) { - self.renderer.render_configurator(configurator); - }); - - this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - * When the user adds a product that has optional products, we need to display - * a window to allow the user to choose these extra options - */ - _handleAdd: function ($modal) { - var self = this; - var quantity = parseFloat($modal.find('input[name="add_qty"]').val() || 1); - var product_id = parseInt($modal.find('input[type="hidden"][name="product_id"], input[type="radio"][name="product_id"]:checked').first().val(), 10); - - var modal = new OptionalProductsModal(quantity, product_id, $('body'), $('.js_sale_order_pricelist_id').html()); - - // No optional products found for this product, - // only add the root product - modal.on('options_empty', null, function () { - self._addProducts([{ - product_id: product_id, - quantity: quantity - }]); - }); - - // Add all the selected products - modal.on('confirm', null, function () { - self._addProducts(modal.getSelectedProducts()); - }); - - // Change the buttons translation when the modal is ready - modal.on('modal_ready', null, function ($modal) { - $modal.find('a.go-back').attr('title', _t('Back')); - $modal.find('a.go-back .d-md-inline').html(_t('Back')); - $modal.find('a.confirm').attr('title', _t('Confirm')); - $modal.find('a.confirm .d-md-inline').html(_t('Confirm')); - }); - - modal.appendTo($('body')); - }, - - /** - * @private - * This triggers the close action for the window and adds the product as the "infos" parameter. - * It will allow the caller of this window to handle the added products. - */ - _addProducts: function(products) { - this.do_action({type: 'ir.actions.act_window_close', infos: products}); - } - }); - - return ProductConfiguratorFormController; -}); - -odoo.define('sale.ProductConfiguratorFormView', function (require) { - "use strict"; - - var ProductConfiguratorFormController = require('sale.ProductConfiguratorFormController'); - var ProductConfiguratorFormRenderer = require('sale.ProductConfiguratorFormRenderer'); - var FormView = require('web.FormView'); - var viewRegistry = require('web.view_registry'); - - var ProductConfiguratorFormView = FormView.extend({ - config: _.extend({}, FormView.prototype.config, { - Controller: ProductConfiguratorFormController, - Renderer: ProductConfiguratorFormRenderer, - }), - }); - - viewRegistry.add('product_configurator_form', ProductConfiguratorFormView); - - return ProductConfiguratorFormView; -}); \ No newline at end of file diff --git a/addons/sale/static/src/js/product_configurator_controller.js b/addons/sale/static/src/js/product_configurator_controller.js new file mode 100644 index 00000000000..f0c7e1b014e --- /dev/null +++ b/addons/sale/static/src/js/product_configurator_controller.js @@ -0,0 +1,137 @@ +odoo.define('sale.ProductConfiguratorFormController', function (require) { +"use strict"; + +var core = require('web.core'); +var _t = core._t; +var FormController = require('web.FormController'); +var OptionalProductsModal = require('sale.OptionalProductsModal'); + +var ProductConfiguratorFormController = FormController.extend({ + custom_events: _.extend({}, FormController.prototype.custom_events, { + field_changed: '_onFieldChanged' + }), + className: 'o_product_configurator', + /** + * @override + */ + init: function (){ + this._super.apply(this, arguments); + }, + /** + * We need to override the default click behavior for our "Add" button + * because there is a possibility that this product has optional products. + * If so, we need to display an extra modal to choose the options + * + * @override + */ + _onButtonClicked: function (event) { + if (event.stopPropagation){ + event.stopPropagation(); + } + var attrs = event.data.attrs; + if (attrs.special === 'cancel') { + this._super.apply(this, arguments); + } else { + if (!this.$el + .parents('.modal') + .find('.o_sale_product_configurator_add') + .hasClass('disabled')){ + this._handleAdd(this.$el); + } + } + }, + /** + * This is overridden to allow catching the "select" event on our product template select field. + * This will not work anymore if more fields are added to the form. + * TODO: Find a better way to catch that event. + * + * @override + */ + _onFieldChanged: function (event) { + var self = this; + + this.$el.parents('.modal').find('.o_sale_product_configurator_add').removeClass('disabled'); + + this._rpc({ + route: '/product_configurator/configure', + params: { + product_id: event.data.changes.product_template_id.id, + pricelist_id: $('.js_sale_order_pricelist_id').html() + } + }).then(function (configurator) { + self.renderer.renderConfigurator(configurator); + }); + + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * When the user adds a product that has optional products, we need to display + * a window to allow the user to choose these extra options + * + * @private + */ + _handleAdd: function ($modal) { + var productSelector = [ + 'input[type="hidden"][name="product_id"]', + 'input[type="radio"][name="product_id"]:checked' + ]; + + this.rootProduct = { + product_id: parseInt($modal.find(productSelector.join(', ')).first().val(), 10), + quantity: parseFloat($modal.find('input[name="add_qty"]').val() || 1), + product_custom_variant_values: this.renderer.getCustomVariantValues($modal.find('.js_product')) + }; + + this.optionalProductsModal = new OptionalProductsModal($('body'), { + rootProduct: this.rootProduct, + pricelistId: $('.js_sale_order_pricelist_id').html(), + okButtonText: _t('Confirm'), + cancelButtonText: _t('Back'), + title: _t('Configure') + }).open(); + + this.optionalProductsModal.on('options_empty', null, this._onModalOptionsEmpty.bind(this)); + this.optionalProductsModal.on('confirm', null, this._onModalConfirm.bind(this)); + }, + + /** + * No optional products found for this product, only add the root product + * + * @private + */ + _onModalOptionsEmpty: function () { + this._addProducts([this.rootProduct]); + }, + + /** + * Add all selected products + * + * @private + */ + _onModalConfirm: function () { + this._addProducts(this.optionalProductsModal.getSelectedProducts()); + }, + + /** + * This triggers the close action for the window and + * adds the product as the "infos" parameter. + * It will allow the caller of this window to handle the added products. + * + * @private + * @param {Array} products the list of added products + * {integer} products.product_id: the id of the product + * {integer} products.quantity: the added quantity for this product + */ + _addProducts: function (products) { + this.do_action({type: 'ir.actions.act_window_close', infos: products}); + } +}); + +return ProductConfiguratorFormController; + +}); \ No newline at end of file diff --git a/addons/sale/static/src/js/product_configurator_list_editable_renderer.js b/addons/sale/static/src/js/product_configurator_list_editable_renderer.js deleted file mode 100644 index f69fb47a4af..00000000000 --- a/addons/sale/static/src/js/product_configurator_list_editable_renderer.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * This adds the possibility to open the product configurator when the control - * "Configure a product" is clicked - */ -odoo.define('sale.ProductConfiguratorEditableListRenderer', function (require) { - "use strict"; - - var core = require('web.core'); - var _t = core._t; - var ListRenderer = require('web.ListRenderer'); - - ListRenderer.include({ - /** - * @override - */ - _onAddRecord: function (ev) { - // we don't want the browser to navigate to a the # url - ev.preventDefault(); - - // we don't want the click to cause other effects, such as unselecting - // the row that we are creating, because it counts as a click on a tr - ev.stopPropagation(); - - // but we do want to unselect current row - var self = this; - this.unselectRow().then(function () { - var open_product_configurator = false; - var context = ev.currentTarget.dataset.context; - - var context_json = false; - if(context){ - context = context.replace(/\'/g, '"'); - context_json = JSON.parse(context); - - open_product_configurator = context_json.open_product_configurator; - } - - if(open_product_configurator){ - self._rpc({ - model: 'ir.model.data', - method: 'xmlid_to_res_id', - kwargs: {xmlid: 'sale.sale_product_configurator_view_form'}, - }).then(function(res_id) { - self.do_action({ - name: _t('Configure a product'), - type: 'ir.actions.act_window', - res_model: 'sale.product.configurator', - views: [[res_id, 'form']], - target: 'new' - }, { - on_close: function (products) { - if(products && products != 'special'){ - self.trigger_up('add_record', { - context: products, - forceEditable: "bottom" , - allowWarning: true, - onSuccess: function(){ - self.unselectRow(); - } - }); - } - } - }); - }); - } else { - self.trigger_up('add_record', {context: context && [context]}); // TODO write a test, the deferred was not considered - } - }); - } - }); -}); \ No newline at end of file diff --git a/addons/sale/static/src/js/product_configurator_mixin.js b/addons/sale/static/src/js/product_configurator_mixin.js new file mode 100644 index 00000000000..ee5968255a8 --- /dev/null +++ b/addons/sale/static/src/js/product_configurator_mixin.js @@ -0,0 +1,437 @@ +odoo.define('sale.ProductConfiguratorMixin', function (require) { +'use strict'; + +var core = require('web.core'); +var utils = require('web.utils'); +var ajax = require('web.ajax'); +var _t = core._t; + +var variantsSelectors = [ + 'input.js_variant_change', + 'select.js_variant_change', + 'input.js_product_change', + '[data-attribute_value_ids]' +]; +var variantChangeEvent = 'change ' + variantsSelectors.join(', '); +var events = { + 'click .css_attribute_color input': '_onChangeColorAttribute', + 'change input.js_quantity': 'onChangeAddQuantity', + 'click button.js_add_cart_json': 'onClickAddCartJSON', +}; +events[variantChangeEvent] = 'onChangeVariant'; + +var ProductConfiguratorMixin = { + events: events, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * When a product is added or when the quantity is changed, + * we need to refresh the total price row + * TODO (awa): add a container context to avoid global selectors ? + * + */ + computePriceTotal: function () { + if ($('.js_price_total').length){ + var price = 0; + $('.js_product.in_cart').each(function (){ + var quantity = parseInt($('input[name="add_qty"]').first().val()); + price += parseFloat($(this).find('.js_raw_price').html()) * quantity; + }); + + $('.js_price_total .oe_currency_value').html( + this._priceToStr(parseFloat(price)) + ); + } + }, + + /** + * When a variant is changed, this will check: + * - If the selected combination is available or not + * - The extra price if applicable + * - The display name of the product ("Customizable desk (White, Steel)") + * - The new total price + * - The need of adding a "custom value" input + * TODO (awa): change ugly [0], [1], [2], ... to a dict + * + * @param {MouseEvent} + * @param {string} params.imagesSize "small" or "medium" or "" + */ + onChangeVariant: function (ev, imagesSize) { + var self = this; + + var $parent = $(ev.target).closest('.js_product'); + var $ul = $parent.find('.js_add_cart_variants'); + var $product_id = $parent.find('.product_id').first(); + var $product_display_name = $parent.find('.product_display_name').first(); + var $product_raw_price = $parent.find('.js_raw_price').first(); + var $price = $parent.find(".oe_price:first .oe_currency_value"); + var $default_price = $parent.find(".oe_default_price:first .oe_currency_value"); + var $optional_price = $parent.find(".oe_optional:first .oe_currency_value"); + var variant_ids = $ul.data("attribute_value_ids"); + var values = []; + var unchanged_values = $parent + .find('div.oe_unchanged_value_ids') + .data('unchanged_value_ids') || []; + + var variantsValuesSelectors = [ + 'input.js_variant_change:checked', + 'select.js_variant_change' + ]; + _.each($parent.find(variantsValuesSelectors.join(', ')), function (el) { + values.push(+$(el).val()); + }); + values = values.concat(unchanged_values); + var list_variant_id = parseInt($parent.find('input.js_product_change:checked').val()); + + $parent.find("label").removeClass('text-muted css_not_available'); + + var product_id = false; + var display_name = false; + var product_raw_price = false; + for (var k in variant_ids) { + if (_.isEmpty(_.difference(variant_ids[k][1], values)) + || variant_ids[k][0] === list_variant_id) { + $price.html(self._priceToStr(variant_ids[k][2])); + $default_price.html(self._priceToStr(variant_ids[k][3])); + if (variant_ids[k][3]-variant_ids[k][2]>0.01) { + $default_price + .closest('.oe_website_sale') + .addClass("discount"); + $optional_price + .closest('.oe_optional') + .show() + .css('text-decoration', 'line-through'); + $default_price.parent().removeClass('d-none'); + } else { + $optional_price.closest('.oe_optional').hide(); + $default_price.parent().addClass('d-none'); + } + product_id = variant_ids[k][0]; + product_raw_price = variant_ids[k][2]; + display_name = variant_ids[k][4]; + var rootComponentSelectors = [ + 'tr.js_product', + '.oe_website_sale', + '.o_product_configurator' + ]; + self._updateProductImage( + $(ev.currentTarget).closest(rootComponentSelectors.join(', ')), + product_id, + imagesSize); + break; + } + } + + var variantsSelectors = [ + 'input.js_variant_change:radio', + 'select.js_variant_change' + ]; + + _.each($parent.find(variantsSelectors.join(', ')), function (elem) { + var $input = $(elem); + var id = +$input.val(); + var values = [id]; + + variantsValuesSelectors = ['select.js_variant_change']; + var checkedVariantSelector = "ul:not(:has(input.js_variant_change[value='" + id + "']))"; + checkedVariantSelector += " input.js_variant_change:checked"; + variantsValuesSelectors.unshift(checkedVariantSelector); + + _.each($parent.find(variantsValuesSelectors.join(', ')), function (elem) { + values.push(+$(elem).val()); + }); + + for (var k in variant_ids) { + if (!_.difference(values, variant_ids[k][1]).length) { + return; + } + } + $input.closest("label").addClass("css_not_available"); + $input.find("option[value='" + id + "']").addClass("css_not_available"); + }); + + if (product_id) { + $parent.removeClass("css_not_available"); + $product_id.val(product_id); + $product_display_name.val(display_name); + $product_raw_price.html(product_raw_price); + $parent + .parents('.modal') + .find('.o_sale_product_configurator_add') + .removeClass("disabled"); + + var $add_to_cart_button = $parent.find("#add_to_cart"); + if (!$add_to_cart_button.hasClass('out_of_stock')) { + $add_to_cart_button.removeClass("disabled"); + } + } else { + $parent.addClass("css_not_available"); + $product_id.val(0); + $product_display_name.val(''); + $parent.find("#add_to_cart").addClass("disabled"); + $parent + .parents('.modal') + .find('.o_sale_product_configurator_add') + .addClass("disabled"); + } + + this.computePriceTotal(); + this.handleCustomValues(ev); + }, + + /** + * Will add the "custom value" input for this attribute value if necessary + * + * @private + * @param {MouseEvent} ev + */ + handleCustomValues: function (ev){ + var $variantContainer; + var addCustomValueField = false; + var attributeValueId = false; + var attributeValueName = false; + if ($(ev.target).is('input[type=radio]') && $(ev.target).is(':checked')) { + $variantContainer = $(ev.target).closest('ul').closest('li'); + addCustomValueField = $(ev.target).data('is_custom'); + attributeValueId = $(ev.target).data('value_id'); + attributeValueName = $(ev.target).data('value_name'); + } else if ($(ev.target).is('select')){ + $variantContainer = $(ev.target).closest('li'); + var $selectedOption = $(ev.target) + .find('option[value="' + $(ev.target).val() + '"]'); + addCustomValueField = $selectedOption.data('is_custom'); + attributeValueId = $selectedOption.data('value_id'); + attributeValueName = $selectedOption.data('value_name'); + } + + if ($variantContainer) { + if (addCustomValueField && attributeValueId && attributeValueName) { + if ($variantContainer.find('.variant_custom_value').length === 0 + || $variantContainer + .find('.variant_custom_value') + .data('attribute_value_id') !== parseInt(attributeValueId)){ + $variantContainer.find('.variant_custom_value_label').remove(); + $variantContainer.find('.variant_custom_value').remove(); + + var $label = $('