diff options
| author | Aurélien Warnon <awa@odoo.com> | 2018-09-07 14:57:13 +0200 |
|---|---|---|
| committer | Aurélien Warnon <awa@odoo.com> | 2018-09-25 18:39:32 +0200 |
| commit | df2ea31ff6eb5978c700d0ca0d096c4b1b33bee1 (patch) | |
| tree | 6a3002295cd0d9cd71dc685cb9ae432b2f7f5fb5 /addons | |
| parent | d3fbd102694bedaedac7016022a7f08d95a10553 (diff) | |
[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
Diffstat (limited to 'addons')
31 files changed, 1396 insertions, 854 deletions
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!</field> <field name="optional_product_ids" eval="[(6,0,[ref('product_product_1_product_template')])]"/> </record> - <!-- + <record id="product_attribute_value_7" model="product.attribute.value"> + <field name="name">Custom</field> + <field name="attribute_id" ref="product.product_attribute_1"/> + <field name="is_custom">True</field> + </record> + + <record id="product.product_attribute_line_1" model="product.attribute.line"> + <field name="value_ids" eval="[(4,ref('product_attribute_value_7'))]"/> + </record> + + <record id="product_product_4e" model="product.product"> + <field name="default_code">DESK0005</field> + <field name="product_tmpl_id" ref="product.product_product_4_product_template"/> + <field name="attribute_value_ids" eval="[(6,0,[ref('product_attribute_value_7'), ref('product.product_attribute_value_3')])]"/> + </record> + + <record id="product_product_4f" model="product.product"> + <field name="default_code">DESK0006</field> + <field name="product_tmpl_id" ref="product.product_product_4_product_template"/> + <field name="attribute_value_ids" eval="[(6,0,[ref('product_attribute_value_7'), ref('product.product_attribute_value_4')])]"/> + </record> + + <!-- Handle automatically created product.product.attribute.value. Check "product.product_product_attribute_value_1" for more information about this --> @@ -759,7 +781,7 @@ Thanks!</field> <field name="value_ids" eval="[(6,0,[ref('sale.product_product_attribute_value_4')])]"/> </record> - <!-- + <!-- The "Customizable Desk's Aluminium" attribute value will excude: - The "Customizable Desk's Black" attribute - The "Office Chair's Steel" attribute @@ -773,7 +795,7 @@ Thanks!</field> <field name="value_ids" eval="[(6,0,[ref('product.product_product_attribute_value_2')])]"/> </record> - <!-- + <!-- The "Customizable Desk's Steel" attribute value will excude: - The "Office Chair's Aluminium" attribute --> 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('<div class="configurator_container"></div>'); - }, - - //-------------------------------------------------------------------------- - // 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 = $('<label>', { + html: attributeValueName + ': ', + class: 'variant_custom_value_label' + }); + + var $input = $('<input>', { + type: 'text', + 'data-attribute_value_id': attributeValueId, + 'data-attribute_value_name': attributeValueName, + class: 'variant_custom_value form-control' + }); + $variantContainer.append($label).append($input); + } + } else { + $variantContainer.find('.variant_custom_value_label').remove(); + $variantContainer.find('.variant_custom_value').remove(); + } + } + }, + + /** + * Hack to add and remove from cart with json + * + * @param {MouseEvent} ev + */ + onClickAddCartJSON: function (ev) { + ev.preventDefault(); + var $link = $(ev.currentTarget); + var $input = $link.closest('.input-group').find("input"); + var product_id = +$input + .closest('*:has(input[name="product_id"])') + .find('input[name="product_id"]') + .val(); + var min = parseFloat($input.data("min") || 0); + var max = parseFloat($input.data("max") || Infinity); + var quantity = ($link.has(".fa-minus").length ? -1 : 1) + parseFloat($input.val() || 0, 10); + var new_qty = quantity > min ? (quantity < max ? quantity : max) : min; + // if they are more of one input for this product (eg: option modal) + $('input[name="'+$input.attr("name")+'"]').add($input).filter(function () { + var $prod = $(this).closest('*:has(input[name="product_id"])'); + return !$prod.length || +$prod.find('input[name="product_id"]').val() === product_id; + }).val(new_qty).trigger('change', [false]); + return false; + }, + + /** + * When the quantity is changed, we need to query the new price of the product. + * Based on the price list, the price might change when quantity exceeds X + * + * @param {MouseEvent} ev + */ + onChangeAddQuantity: function (ev, noStockCheck) { + var self = this; + var $parent; + if ($(ev.currentTarget).closest('form').length > 0){ + $parent = $(ev.currentTarget).closest('form'); + } else if ($(ev.currentTarget).closest('.oe_optional_products_modal').length > 0){ + $parent = $(ev.currentTarget).closest('.oe_optional_products_modal'); + } else { + $parent = $(ev.currentTarget).closest('.o_product_configurator'); + } + + var qty = $parent.find('input[name="add_qty"]').val(); + var productIDs = []; + $parent.find(".js_product .js_add_cart_variants").each(function () { + _.each($(this).data("attribute_value_ids"), function (entry){ + productIDs.push(entry[0]); + }); + }); + + var getPriceUrl = '/product_configurator/get_unit_price' + (this.isWebsite ? '_website' : ''); + if (productIDs.length) { + ajax.jsonRpc(getPriceUrl, 'call', { + product_ids: productIDs, + add_qty: parseInt(qty), + pricelist_id: this.pricelistId + }).then(function (data) { + $parent.find(".js_product .js_add_cart_variants").each(function () { + var currentAttributesData = $(this).data("attribute_value_ids"); + for (var j = 0 ; j < currentAttributesData.length ; j++) { + // update the price of the product (index "2") based on its id (index "0") + currentAttributesData[j][2] = data[currentAttributesData[j][0]]; + } + $(this).trigger('change', [noStockCheck]); + + self.computePriceTotal(); + }); + }); + } + }, + + /** + * Triggers the price computation and other variant specific changes + * + * @param {$.Element} $container + */ + triggerVariantChange: function ($container) { + $container.find('.js_add_cart_variants li[data-attribute_id]').each(function () { + $(this) + .find('input.js_variant_change, select.js_variant_change') + .first() + .trigger('change'); + }); + }, + + /** + * Will look for user custom attribute values + * in the provided container + * + * @param {$.Element} $container + * @returns {Array} array of custom values with the following format + * {integer} attribute_value_id + * {string} attribute_value_name + * {string} custom_value + */ + getCustomVariantValues: function ($container) { + var variantCustomValues = []; + $container.find('.variant_custom_value').each(function (){ + var $variantCustomValueInput = $(this); + if ($variantCustomValueInput.length !== 0){ + variantCustomValues.push({ + 'attribute_value_id': $variantCustomValueInput.data('attribute_value_id'), + 'attribute_value_name': $variantCustomValueInput.data('attribute_value_name'), + 'custom_value': $variantCustomValueInput.val(), + }); + } + }); + + return variantCustomValues; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * returns the formatted price + * + * @private + * @param {float} price + */ + _priceToStr: function (price) { + var l10n = _t.database.parameters; + var precision = 2; + + if ($('.decimal_precision').length) { + precision = parseInt($('.decimal_precision').last().data('precision')); + } + var formatted = _.str.sprintf('%.' + precision + 'f', price).split('.'); + formatted[0] = utils.insert_thousand_seps(formatted[0]); + return formatted.join(l10n.decimal_point); + }, + + /** + * Updates the product image + * + * @private + * @param {$.Element} $productContainer + * @param {integer} product_id + * @param {string} imagesSize + */ + _updateProductImage: function ($productContainer, product_id, imagesSize) { + var $img; + var imageSrc = '/web/image?model=product.product&id={0}&field={1}' + .replace("{0}", product_id) + .replace("{1}", _.isEmpty(imagesSize) ? 'image' : ('image_' + imagesSize)); + + if ($productContainer.find('#o-carousel-product').length) { + $img = $productContainer.find('img.js_variant_img'); + $img.attr("src", imageSrc); + $img.parent().attr('data-oe-model', 'product.product').attr('data-oe-id', product_id) + .data('oe-model', 'product.product').data('oe-id', product_id); + + var $thumbnail = $productContainer.find('img.js_variant_img_small'); + if ($thumbnail.length !== 0) { // if only one, thumbnails are not displayed + $thumbnail.attr("src", "/web/image/product.product/" + product_id + "/image/90x90"); + $('.carousel').carousel(0); + } + } + else { + var imagesSelectors = [ + 'span[data-oe-model^="product."][data-oe-type="image"] img:first', + 'img.product_detail_img', + 'span.variant_image img' + ]; + + $img = $productContainer.find(imagesSelectors.join(', ')); + $img.attr('src', imageSrc); + $img.parent() + .attr('data-oe-model', 'product.product') + .attr('data-oe-id', product_id) + .data('oe-model', 'product.product') + .data('oe-id', product_id); + } + // reset zooming constructs + $img.filter('[data-zoom-image]').attr('data-zoom-image', $img.attr('src')); + if ($img.data('zoomOdoo') !== undefined) { + $img.data('zoomOdoo').isReady = false; + } + }, + + /** + * Highlight selected color + * + * @private + * @param {MouseEvent} ev + */ + _onChangeColorAttribute: function (ev) { + var $parent = $(ev.target).closest('.js_product'); + $parent.find('.css_attribute_color') + .removeClass("active") + .filter(':has(input:checked)') + .addClass("active"); + } +}; + +return ProductConfiguratorMixin; + +});
\ No newline at end of file diff --git a/addons/sale/static/src/js/product_configurator_modal.js b/addons/sale/static/src/js/product_configurator_modal.js index 0c0c1c422ba..1df9c165d21 100644 --- a/addons/sale/static/src/js/product_configurator_modal.js +++ b/addons/sale/static/src/js/product_configurator_modal.js @@ -2,28 +2,45 @@ odoo.define('sale.OptionalProductsModal', function (require) { "use strict"; var ajax = require('web.ajax'); -var Widget = require('web.Widget'); +var Dialog = require('web.Dialog'); var ServicesMixin = require('web.ServicesMixin'); +var ProductConfiguratorMixin = require('sale.ProductConfiguratorMixin'); var weContext = require('web_editor.context'); -var ProductConfiguratorUtils = require('sale.product_configurator_utils'); var product_name_map = {}; var optional_products_map = {}; -var OptionalProductsModal = Widget.extend(ServicesMixin, { - events: _.extend({}, Widget.prototype.events, { +var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixin, { + events: _.extend({}, Dialog.prototype.events, ProductConfiguratorMixin.events, { + 'click a.js_add, a.js_remove': '_onAddOrRemoveOption', + 'change input[name="add_qty"]': '_onChangeMainProductQuantity', 'click .css_attribute_color input': '_onColorClick', + 'change input.js_quantity': '_onChangeQuantity' }), /** + * Initializes the optional products modal + * * @override */ - init: function (quantity, product_id, container, pricelistId, isWebsite) { - this._super.apply(this, arguments); - this.quantity = quantity; - this.product_id = product_id; - this.container = container; - this.pricelistId = pricelistId; - this.isWebsite = isWebsite; + init: function (parent, params) { + this._super(parent, { + size: 'large', + buttons: [{ + text: params.okButtonText, + click: this._onConfirmButtonClick, + classes: 'btn-primary' + }, { + text: params.cancelButtonText, + click: this._onCancelButtonClick + }], + title: params.title + }); + + this.rootProduct = params.rootProduct; + this.container = parent; + this.pricelistId = params.pricelistId; + this.isWebsite = params.isWebsite; + this.dialogClass = 'oe_optional_products_modal' + (params.isWebsite ? ' oe_website_sale' : ''); }, /** * @override @@ -31,26 +48,69 @@ var OptionalProductsModal = Widget.extend(ServicesMixin, { willStart: function () { var self = this; - var getModalContent = ajax.jsonRpc(this._getUri("/product_configurator/show_optional_products"), 'call', { - product_id: self.product_id, + var uri = this._getUri("/product_configurator/show_optional_products"); + var getModalContent = ajax.jsonRpc(uri, 'call', { + product_id: self.rootProduct.product_id, pricelist_id: self.pricelistId, kwargs: { - context: _.extend({'quantity': self.quantity}, weContext.get()), + context: _.extend({'quantity': self.rootProduct.quantity}, weContext.get()), } }) - .then(function (modal_content) { - self.modal_content = modal_content; + .then(function (modalContent) { + if (modalContent){ + var $modalContent = $(modalContent); + $modalContent = self._postProcessContent($modalContent); + self.$content = $modalContent; + } else { + self.trigger('options_empty'); + self.preventOpening = true; + } }); var parentInit = self._super.apply(self, arguments); return $.when(getModalContent, parentInit); }, + /** + * Show a dialog + * + * @param {Object} options + * @param {boolean} options.shouldFocusButtons if true, put the focus on + * the first button primary when the dialog opens + */ + open: function (options) { + $('.tooltip').remove(); // remove open tooltip if any to prevent them staying when modal is opened + + var self = this; + this.appendTo($('<div/>')).then(function () { + if (!self.preventOpening){ + self.$modal.find(".modal-body").replaceWith(self.$el); + self.$modal.attr('open', true); + self.$modal.removeAttr("aria-hidden"); + self.$modal.modal().appendTo(self.container); + self._opened.resolve(); + } + }); + if (options && options.shouldFocusButtons) { + self._onFocusControlButton(); + } + + return self; + }, + /** + * Will: + * - trigger add_qty change to synchronize quantity with previous window + * - trigger variant change to compute the price and other + * variant specific changes + * + * * @override */ - start: function() { - this._renderModal(); - return this._super.apply(this, arguments); + start: function (){ + this._super.apply(this, arguments); + + this.$el.find('input[name="add_qty"]').val(this.rootProduct.quantity).change(); + this.triggerVariantChange(this.$el); }, //-------------------------------------------------------------------------- @@ -58,24 +118,32 @@ var OptionalProductsModal = Widget.extend(ServicesMixin, { //-------------------------------------------------------------------------- /** - * @public * Returns the list of selected products as products[{product_id, quantity}] + * + * @public */ - getSelectedProducts: function() { + getSelectedProducts: function () { var self = this; - var products = []; - this.$modal.find('.js_product.in_cart').each(function(){ + var products = [this.rootProduct]; + this.$modal.find('.js_product.in_cart:not(.main_product)').each(function (){ var quantity = 0; var $item = $(this); - if($item.find('input[name="add_qty"]').length){ + if ($item.find('input[name="add_qty"]').length){ quantity = $item.find('input[name="add_qty"]').val(); } else { - quantity = parseInt($item.find('.optional_product_quantity span.add_qty').html().trim()); + quantity = parseInt( + $item + .find('.optional_product_quantity span.add_qty') + .html() + .trim() + ); } - + + var productCustomVariantValues = self.getCustomVariantValues($(this)); products.push({ - product_id: $item.find('input.product_id').length ? $item.find('input.product_id').val() : self.product_id, - quantity: quantity + product_id: parseInt($item.find('input.product_id').val()), + quantity: quantity, + product_custom_variant_values: productCustomVariantValues }); }); @@ -86,34 +154,40 @@ var OptionalProductsModal = Widget.extend(ServicesMixin, { // Private // ------------------------------------------ - _renderModal: function(){ - var self = this; - if (self.modal_content){ - var $modal = $(self.modal_content); - $modal.find('img:first').attr("src", "/web/image/product.product/" + self.product_id + "/image_medium"); - - $modal - .modal() - .appendTo(self.container) - .addClass('oe_optional_products_modal' + (self.isWebsite ? ' oe_website_sale' : '')) - .on('hidden.bs.modal', function () { - $(this).remove(); - }) - .on('shown.bs.modal', function () { - self.trigger('modal_ready', $modal); - }); + /** + * Adds the product image and updates the product description + * + * @private + */ + _postProcessContent: function ($modalContent) { + var productId = this.rootProduct.product_id; + $modalContent + .find('img:first') + .attr("src", "/web/image/product.product/" + productId + "/image_medium"); + + if (this.rootProduct && this.rootProduct.product_custom_variant_values) { + var $productDescription = $modalContent + .find('.main_product') + .find('td.td-product_name div.text-muted.small'); + var description = $productDescription.html(); + $.each(this.rootProduct.product_custom_variant_values, function (){ + description += '<br/>' + this.attribute_value_name + ': ' + this.custom_value; + }); - self.$modal = $modal; - self._addModalEvents(); - } else { - self.trigger('options_empty'); + $productDescription.html(description); } + + return $modalContent; }, /** - * Website behavior is slighlty different from backend so we append "_website" to URLs to lead to a different route + * Website behavior is slightly different from backend so we append + * "_website" to URLs to lead to a different route + * + * @private + * @param {string} uri The uri to adapt */ - _getUri: function(uri) { + _getUri: function (uri) { if (this.isWebsite){ return uri + '_website'; } else { @@ -121,56 +195,49 @@ var OptionalProductsModal = Widget.extend(ServicesMixin, { } }, - _addModalEvents: function() { - this.$modal.on('click', '.a-submit', {self: this}, this._onSubmit); - this.$modal.on('click', '.css_attribute_color input', this._onColorClick); - this.$modal.on("click", 'a.js_add, a.js_remove', {self: this}, this._onAddOrRemoveOption); - this.$modal.on("change", "input.js_quantity", {self: this}, this._onChangeQuantity); - this.$modal.on("change", 'input[name="add_qty"]', this._onChangeMainProductQuantity); - ProductConfiguratorUtils.addConfiguratorEvents(this.$modal, "small", this.isWebsite, this.pricelistId); - - // trigger add_qty change to synchronise quantity with previous window - this.$modal.find('input[name="add_qty"]').val(this.quantity).change(); - - // trigger input change to validate fields and display custom prices - $('.js_add_cart_variants', this.$modal).each(function () { - $('input.js_variant_change, select.js_variant_change', this).first().trigger('change'); - }); + _onConfirmButtonClick: function () { + this.trigger('confirm'); + this.close(); }, - /** - * Handles the "Back" and "Confirm" buttons and triggers the according events - */ - _onSubmit: function(ev){ - var $modal = $(this).parents('.oe_optional_products_modal'); - $modal.modal('hide'); - ev.preventDefault(); - - var $a = $(this); - ev.data.self.trigger($a.hasClass('confirm') ? 'confirm' : 'back'); + _onCancelButtonClick: function () { + this.trigger('back'); + this.close(); }, - /** - * TODO (awa): check if necessary - */ - _onColorClick: function(){ - var $modal = $(this).parents('.oe_optional_products_modal'); + _onColorClick: function (ev){ + var $modal = $(ev.currentTarget).parents('.oe_optional_products_modal'); $modal.find('.css_attribute_color').removeClass("active"); $modal.find('.css_attribute_color:has(input:checked)').addClass("active"); + + this._onChangeColorAttribute(ev); }, - _onAddOrRemoveOption: function(ev){ + /** + * Will add/remove the option, that includes: + * - Moving it to the correct DOM section + * and possibly under its parent product + * - Hiding variants and showing the quantity + * - Remove optional products if parent product is removed + * - Compute the total price + * + * @private + * @param {MouseEvent} ev + */ + _onAddOrRemoveOption: function (ev){ ev.preventDefault(); - var $modal = $(this).parents('.oe_optional_products_modal'); + var self = this; + var $target = $(ev.currentTarget); + var $modal = $target.parents('.oe_optional_products_modal'); var $select_options_text = $modal.find('.o_select_options'); var $main_product = $modal.find('.js_product:first'); - var $parent = $(this).parents('.js_product:first'); + var $parent = $target.parents('.js_product:first'); $parent.find("a.js_add, span.js_remove").toggleClass('d-none'); - $parent.find("input.js_optional_same_quantity").val( $(this).hasClass("js_add") ? 1 : 0 ); + $parent.find("input.js_optional_same_quantity").val($target.hasClass("js_add") ? 1 : 0 ); $parent.find(".js_remove"); var product_id = $parent.find(".product_template_id").val(); - if ($(this).hasClass('js_add')) { + if ($target.hasClass('js_add')) { // remove attribute values selection and move quantity to the correct table spot (".td-qty")) $parent.addClass('in_cart'); $parent.find('.td-qty').addClass('text-center'); @@ -181,11 +248,29 @@ var OptionalProductsModal = Widget.extend(ServicesMixin, { product_name_map[product_id] = $parent.find(".product-name").html(); $parent.find(".product-name").html($parent.find(".product_display_name").val()); + var product_custom_variant_values = self.getCustomVariantValues($parent); + if (product_custom_variant_values) { + var $productDescription = $parent + .find('td.td-product_name div.float-left'); + + var description = ''; + $.each(product_custom_variant_values, function (){ + description += '<br/>' + this.attribute_value_name + ': ' + this.custom_value; + }); + + var $customAttributeValuesDescription = $('<div>', { + class: 'custom_attribute_values_description text-muted small', + html: description + }); + + $productDescription.append($customAttributeValuesDescription); + } + // if it's an optional product of an optional product, place it after it's parent var parent_product_id = null; - for (var produt_id_key in optional_products_map) { - if (optional_products_map[produt_id_key].indexOf(product_id) != -1) { - parent_product_id = produt_id_key; + for (var product_id_key in optional_products_map) { + if (optional_products_map[product_id_key].indexOf(product_id) !== -1) { + parent_product_id = product_id_key; break; } @@ -201,18 +286,14 @@ var OptionalProductsModal = Widget.extend(ServicesMixin, { $main_product.after($parent); } - var isWebsite = ev.data.self.isWebsite; - var pricelistId = ev.data.self.pricelistId; var product_variant_id = $parent.find('.product_id').val(); - ajax.jsonRpc(ev.data.self._getUri("/product_configurator/optional_product_items"), 'call', { + ajax.jsonRpc(self._getUri("/product_configurator/optional_product_items"), 'call', { 'product_id': product_variant_id, - 'pricelist_id': ev.data.self.pricelistId + 'pricelist_id': self.pricelistId }).then(function (addedItem) { var $addedItem = $(addedItem); $modal.find('tr:last').after($addedItem); - ProductConfiguratorUtils.addConfiguratorEvents($addedItem, "small", isWebsite, pricelistId); - // trigger input change to validate fields $('.js_add_cart_variants', $addedItem).each(function () { $('input.js_variant_change, select.js_variant_change', this).first().trigger('change'); @@ -229,8 +310,8 @@ var OptionalProductsModal = Widget.extend(ServicesMixin, { return $(this).val(); }).get(); } - - if($select_options_text.nextAll('.js_product').length === 0){ + + if ($select_options_text.nextAll('.js_product').length === 0){ // no more optional products to select -> hide the header $select_options_text.hide(); } @@ -240,6 +321,7 @@ var OptionalProductsModal = Widget.extend(ServicesMixin, { $parent.removeClass('in_cart'); $parent.find('.td-qty').removeClass('text-center'); $parent.find('.js_remove.d-none').prepend($parent.find('.optional_product_quantity')); + $parent.find('.custom_attribute_values_description').remove(); $select_options_text.show(); @@ -260,12 +342,15 @@ var OptionalProductsModal = Widget.extend(ServicesMixin, { $('tr:last').after($parent); } - ProductConfiguratorUtils.computePriceTotal(); + self.computePriceTotal(); }, - _onChangeQuantity: function(ev){ - var qty = parseFloat($(this).val()); - var $modal = $(this).parents('.oe_optional_products_modal'); + _onChangeQuantity: function (ev){ + this.onChangeAddQuantity(ev); + + var $quantity = $(ev.currentTarget); + var qty = parseFloat($quantity.val()); + var $modal = $quantity.parents('.oe_optional_products_modal'); if (qty === 1) { $modal.find(".js_items").addClass('d-none').removeClass('add_qty'); $modal.find(".js_item").removeClass('d-none').addClass('add_qty'); @@ -275,8 +360,9 @@ var OptionalProductsModal = Widget.extend(ServicesMixin, { } }, - _onChangeMainProductQuantity: function(){ - var $modal = $(this).parents('.oe_optional_products_modal'); + _onChangeMainProductQuantity: function (ev){ + var $quantity = $(ev.currentTarget); + var $modal = $quantity.parents('.oe_optional_products_modal'); var product_id = $modal.find('span.oe_price[data-product-id]').first().data('product-id'); var product_ids = [product_id]; var $products_dom = []; @@ -288,11 +374,10 @@ var OptionalProductsModal = Widget.extend(ServicesMixin, { }); }); - // trigger input change to validate fields and display custom prices - $('.js_add_cart_variants', $modal).each(function () { - $('input.js_variant_change, select.js_variant_change', this).first().trigger('change'); - }); + this.triggerVariantChange($modal); } }); + return OptionalProductsModal; + });
\ No newline at end of file diff --git a/addons/sale/static/src/js/product_configurator_renderer.js b/addons/sale/static/src/js/product_configurator_renderer.js new file mode 100644 index 00000000000..7d28669dd15 --- /dev/null +++ b/addons/sale/static/src/js/product_configurator_renderer.js @@ -0,0 +1,52 @@ +odoo.define('sale.ProductConfiguratorFormRenderer', function (require) { +"use strict"; + +var FormRenderer = require('web.FormRenderer'); +var ProductConfiguratorMixin = require('sale.ProductConfiguratorMixin'); + +var ProductConfiguratorFormRenderer = FormRenderer.extend(ProductConfiguratorMixin ,{ + /** + * @override + */ + init: function (){ + this._super.apply(this, arguments); + + this.pricelistId = $('.js_sale_order_pricelist_id').html(); + }, + /** + * @override + */ + start: function () { + this._super.apply(this, arguments); + this.$el.append($('<div>', {class: 'configurator_container'})); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Renders the product configurator within the form + * + * Will also: + * - add events handling for variant changes + * - trigger variant change to compute the price and other + * variant specific changes + * + * @param {string} configuratorHtml the evaluated template of + * the product configurator + */ + renderConfigurator: function (configuratorHtml) { + var $configuratorContainer = this.$('.configurator_container'); + $configuratorContainer.empty(); + + var $configuratorHtml = $(configuratorHtml); + $configuratorHtml.appendTo($configuratorContainer); + + this.triggerVariantChange($configuratorContainer); + } +}); + +return ProductConfiguratorFormRenderer; + +}); diff --git a/addons/sale/static/src/js/product_configurator_utils.js b/addons/sale/static/src/js/product_configurator_utils.js deleted file mode 100644 index c97595075c4..00000000000 --- a/addons/sale/static/src/js/product_configurator_utils.js +++ /dev/null @@ -1,272 +0,0 @@ -odoo.define('sale.product_configurator_utils', function (require) { - 'use strict'; - var core = require('web.core'); - var utils = require('web.utils'); - var ajax = require('web.ajax'); - var _t = core._t; - - /** - * returns the formatted price - * @param {float} price - */ - function _priceToStr (price) { - var l10n = _t.database.parameters; - var precision = 2; - - if ($(".decimal_precision").length) { - precision = parseInt($(".decimal_precision").last().data('precision')); - } - var formatted = _.str.sprintf('%.' + precision + 'f', price).split('.'); - formatted[0] = utils.insert_thousand_seps(formatted[0]); - return formatted.join(l10n.decimal_point); - } - - /** - * 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 ? - */ - function computePriceTotal (){ - 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(_priceToStr(parseFloat(price))); - } - } - - function _updateProductImage ($productContainer, product_id, imagesSize) { - var $img; - var imageSrc = '/web/image?model=product.product&id={0}&field={1}' - .replace("{0}", product_id) - .replace("{1}", imagesSize ? ('image_' + imagesSize) : 'image'); - if ($productContainer.find('#o-carousel-product').length) { - $img = $productContainer.find('img.js_variant_img'); - $img.attr("src", imageSrc); - $img.parent().attr('data-oe-model', 'product.product').attr('data-oe-id', product_id) - .data('oe-model', 'product.product').data('oe-id', product_id); - - var $thumbnail = $productContainer.find('img.js_variant_img_small'); - if ($thumbnail.length !== 0) { // if only one, thumbnails are not displayed - $thumbnail.attr("src", "/web/image/product.product/" + product_id + "/image/90x90"); - $('.carousel').carousel(0); - } - } - else { - $img = $productContainer.find('span[data-oe-model^="product."][data-oe-type="image"] img:first, img.product_detail_img, span.variant_image img'); - $img.attr("src", imageSrc); - $img.parent().attr('data-oe-model', 'product.product').attr('data-oe-id', product_id) - .data('oe-model', 'product.product').data('oe-id', product_id); - } - // reset zooming constructs - $img.filter('[data-zoom-image]').attr('data-zoom-image', $img.attr('src')); - if ($img.data('zoomOdoo') !== undefined) { - $img.data('zoomOdoo').isReady = false; - } - } - - /** - * When a variant is changed, this will compute: - * - If the selected combination is available or not - * - The extra price if applicable - * - The display name of the product ("Customizable desk (White, Steel)") - * TODO (awa): change ugly [0], [1], [2], ... to a dict - */ - function onChangeVariant (ev, imagesSize) { - 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') || []; - - _.each($parent.find('input.js_variant_change:checked, select.js_variant_change'), 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(_priceToStr(variant_ids[k][2])); - $default_price.html(_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]; - _updateProductImage($(ev.currentTarget).closest('tr.js_product, .oe_website_sale, .o_product_configurator'), product_id, imagesSize); - break; - } - } - - _.each($parent.find("input.js_variant_change:radio, select.js_variant_change"), function (elem) { - var $input = $(elem); - var id = +$input.val(); - var values = [id]; - - _.each($parent.find("ul:not(:has(input.js_variant_change[value='" + id + "'])) input.js_variant_change:checked, select.js_variant_change"), 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"); - } - - computePriceTotal(); - } - - /** - * Highlight selected color - */ - function _onChangeColorAttribute (ev) { - var $parent = $(ev.target).closest('.js_product'); - $parent.find('.css_attribute_color') - .removeClass("active") - .filter(':has(input:checked)') - .addClass("active"); - } - - /** - * Hack to add and remove from cart with json - */ - function onClickAddCartJSON (ev) { - ev.preventDefault(); - var $link = $(ev.currentTarget); - var $input = $link.closest('.input-group').find("input"); - var product_id = +$input.closest('*:has(input[name="product_id"])').find('input[name="product_id"]').val(); - var min = parseFloat($input.data("min") || 0); - var max = parseFloat($input.data("max") || Infinity); - var quantity = ($link.has(".fa-minus").length ? -1 : 1) + parseFloat($input.val() || 0, 10); - var new_qty = quantity > min ? (quantity < max ? quantity : max) : min; - // if they are more of one input for this product (eg: option modal) - $('input[name="'+$input.attr("name")+'"]').add($input).filter(function () { - var $prod = $(this).closest('*:has(input[name="product_id"])'); - return !$prod.length || +$prod.find('input[name="product_id"]').val() === product_id; - }).val(new_qty).change(); - return false; - } - - /** - * When the quantity is changed, we need to query the new price of the product. - * Based on the pricelist, the price might change when quantity exceeds X - */ - function onChangeAddQuantity (ev, isWebsite, pricelistId, no_stock_check) { - var $parent; - if($(ev.currentTarget).closest('form').length > 0){ - $parent = $(ev.currentTarget).closest('form'); - } else if($(ev.currentTarget).closest('.oe_optional_products_modal').length > 0){ - $parent = $(ev.currentTarget).closest('.oe_optional_products_modal'); - } else { - $parent = $(ev.currentTarget).closest('.o_product_configurator'); - } - - var qty = $parent.find('input[name="add_qty"]').val(); - var productIDs = []; - $parent.find(".js_product .js_add_cart_variants").each(function () { - _.each($(this).data("attribute_value_ids"), function(entry){ - productIDs.push(entry[0]); - }); - }); - - if (productIDs.length) { - ajax.jsonRpc('/product_configurator/get_unit_price' + (isWebsite ? '_website' : ''), 'call', { - product_ids: productIDs, - add_qty: parseInt(qty), - pricelist_id: pricelistId - }).then(function (data) { - $parent.find(".js_product .js_add_cart_variants").each(function () { - var currentAttributesData = $(this).data("attribute_value_ids"); - for (var j = 0 ; j < currentAttributesData.length ; j++) { - // update the price of the product (index "2") based on its id (index "0") - currentAttributesData[j][2] = data[currentAttributesData[j][0]]; - } - $(this).trigger('change', [no_stock_check]); - - computePriceTotal(); - }); - }); - } - } - - /** - * Adds all the necessary events to handle: - * - Product variant changes - * - Color changes - * - Product price based on pricelist and quantity - * - Total price - * ... - */ - function addConfiguratorEvents ($container, imagesSize, isWebsite, pricelistId) { - $container - .find('.css_attribute_color input') - .change(_onChangeColorAttribute); - - $container - .find('input.js_variant_change, select.js_variant_change, input.js_product_change, [data-attribute_value_ids]') - .change(function(ev, no_stock_check){ - onChangeVariant(ev, imagesSize, no_stock_check); - }); - - $container - .find('input[name="add_qty"]') - .change(function(ev){ - onChangeAddQuantity(ev, isWebsite, pricelistId); - }); - - $container - .find('button.js_add_cart_json') - .click(onClickAddCartJSON); - } - - return { - addConfiguratorEvents: addConfiguratorEvents, - onChangeVariant: onChangeVariant, - onChangeAddQuantity: onChangeAddQuantity, - onClickAddCartJSON: onClickAddCartJSON, - computePriceTotal: computePriceTotal - }; -});
\ No newline at end of file diff --git a/addons/sale/static/src/js/product_configurator_view.js b/addons/sale/static/src/js/product_configurator_view.js new file mode 100644 index 00000000000..5a3bb4401be --- /dev/null +++ b/addons/sale/static/src/js/product_configurator_view.js @@ -0,0 +1,20 @@ +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/scss/product_configurator.scss b/addons/sale/static/src/scss/product_configurator.scss index 91970f144e7..a223118f7c2 100644 --- a/addons/sale/static/src/scss/product_configurator.scss +++ b/addons/sale/static/src/scss/product_configurator.scss @@ -16,6 +16,10 @@ &.active input { margin: 6px; } + + &.custom_value { + background-image: linear-gradient(to bottom right, #FF0000, #FFF200, #1E9600); + } } .css_not_available_msg { @@ -63,6 +67,14 @@ label.css_attribute_color.css_not_available { .product_detail_img { max-height: 240px; } + + .variant_custom_value { + margin-bottom: 10px; + } + + .variant_custom_value_label { + margin-top: 10px; + } } .configurator_container { @@ -86,6 +98,10 @@ label.css_attribute_color.css_not_available { } } +.modal.o_technical_modal .oe_optional_products_modal .btn.js_add_cart_json { + padding: 0.075rem 0.75rem; +} + .js_product { &.in_cart { .js_add_cart_variants { diff --git a/addons/sale/static/tests/product_configurator.test.js b/addons/sale/static/tests/product_configurator.test.js index 768722c6ac9..0eef8a09088 100644 --- a/addons/sale/static/tests/product_configurator.test.js +++ b/addons/sale/static/tests/product_configurator.test.js @@ -1,13 +1,13 @@ odoo.define('sale.product.configurator.tests', function (require) { "use strict"; - + var FormView = require('web.FormView'); var ProductConfiguratorFormView = require('sale.ProductConfiguratorFormView'); var testUtils = require('web.test_utils'); var ajax = require('web.ajax'); var createView = testUtils.createView; - var getArch = function(){ + var getArch = function (){ return '<form>' + '<sheet>' + '<field name="sale_order_line">' + @@ -44,18 +44,30 @@ odoo.define('sale.product.configurator.tests', function (require) { sale_order: { fields: { id: {type: 'integer'}, - sale_order_line: {string: 'lines', type: 'one2many', relation: 'sale_order_line'}, + sale_order_line: { + string: 'lines', + type: 'one2many', + relation: 'sale_order_line' + }, } }, sale_order_line: { fields: { - product_id: {string: 'product', type: 'many2one', relation: 'product'}, + product_id: { + string: 'product', + type: 'many2one', + relation: 'product' + }, quantity: {type: 'integer'} } }, sale_product_configurator: { fields: { - product_template_id: {string: 'product', type: 'many2one', relation: 'product_template'} + product_template_id: { + string: 'product', + type: 'many2one', + relation: 'product_template' + } }, records: [{ product_template_id: 42 @@ -63,10 +75,10 @@ odoo.define('sale.product.configurator.tests', function (require) { }, }; } - }, function(){ + }, function (){ QUnit.test('click on "Configure a product" and check for form loading', function (assert) { assert.expect(2); - + var form = createView({ View: FormView, model: 'sale_order', @@ -80,7 +92,7 @@ odoo.define('sale.product.configurator.tests', function (require) { return this._super.apply(this, arguments); }, }); - + assert.strictEqual(form.$("a:contains('Configure a product')").length, 1); form.$("a:contains('Configure a product')").click(); @@ -88,7 +100,7 @@ odoo.define('sale.product.configurator.tests', function (require) { QUnit.test('trigger_up the "add_record" event and checks that rows are correctly added to the list', function (assert) { assert.expect(1); - + var form = createView({ View: FormView, model: 'sale_order', @@ -97,7 +109,7 @@ odoo.define('sale.product.configurator.tests', function (require) { }); var list = form.renderer.allFieldWidgets[form.handle][0]; - + list.trigger_up('add_record', { context: [{product_id: 1, quantity: 2}, {product_id: 2, quantity: 3}], forceEditable: "bottom" , @@ -107,19 +119,14 @@ odoo.define('sale.product.configurator.tests', function (require) { assert.strictEqual(list.$("tr.o_data_row").length, 2); }); - QUnit.test('Select a product in the list and check for template loading', function(assert){ + QUnit.test('Select a product in the list and check for template loading', function (assert){ assert.expect(1); - ajax.jsonRpc = function(route){ - assert.ok(true); - return $.Deferred().then(_.constant($("<div></div>"))); - }; - var product_configurator_form = createView({ View: ProductConfiguratorFormView, model: 'sale_product_configurator', data: this.data, - arch: + arch: '<form js_class="product_configurator_form">' + '<group>' + '<field name="product_template_id" class="oe_product_configurator_product_template_id" />' + @@ -129,6 +136,13 @@ odoo.define('sale.product.configurator.tests', function (require) { '<button string="Cancel" class="btn-secondary" special="cancel"/>' + '</footer>' + '</form>', + mockRPC: function (route) { + if (route === '/product_configurator/configure') { + assert.ok(true); + return $.Deferred().then(_.constant(1)); + } + return this._super.apply(this, arguments); + } }); var div = '<div>'; var $div = $(div).addClass('js_sale_order_pricelist_id').html(43); diff --git a/addons/sale/views/assets.xml b/addons/sale/views/assets.xml index 81b5c92abf6..d248cf1d226 100644 --- a/addons/sale/views/assets.xml +++ b/addons/sale/views/assets.xml @@ -8,9 +8,11 @@ <xpath expr="script[last()]" position="after"> <script type="text/javascript" src="/sale/static/src/js/sale.js"></script> <script type="text/javascript" src="/sale/static/src/js/tour.js"></script> - <script type="text/javascript" src="/sale/static/src/js/product_configurator_list_editable_renderer.js"></script> - <script type="text/javascript" src="/sale/static/src/js/product_configurator.js"></script> - <script type="text/javascript" src="/sale/static/src/js/product_configurator_utils.js"></script> + <script type="text/javascript" src="/sale/static/src/js/list_editable_renderer.js"></script> + <script type="text/javascript" src="/sale/static/src/js/product_configurator_mixin.js"></script> + <script type="text/javascript" src="/sale/static/src/js/product_configurator_renderer.js"></script> + <script type="text/javascript" src="/sale/static/src/js/product_configurator_controller.js"></script> + <script type="text/javascript" src="/sale/static/src/js/product_configurator_view.js"></script> <script type="text/javascript" src="/sale/static/src/js/product_configurator_modal.js"></script> </xpath> </template> diff --git a/addons/sale/views/product_attribute_views.xml b/addons/sale/views/product_attribute_views.xml index 781e3364bff..90dec1dbd5d 100644 --- a/addons/sale/views/product_attribute_views.xml +++ b/addons/sale/views/product_attribute_views.xml @@ -52,7 +52,7 @@ </field> </field> </record> - + <record id="product_product_attribute_value_view_form_inherit" model="ir.ui.view"> <field name="name">product.product.attribute.value.view.form.inherit</field> <field name="model">product.product.attribute.value</field> @@ -70,6 +70,7 @@ <field name="inherit_id" ref="product.variants_tree_view"/> <field name="arch" type="xml"> <field name="name" position="after"> + <field name="is_custom" groups="product.group_product_variant" /> <field name="html_color"/> </field> </field> diff --git a/addons/sale/views/sale_product_configurator_templates.xml b/addons/sale/views/sale_product_configurator_templates.xml index 3eb6170f019..939cc433efd 100644 --- a/addons/sale/views/sale_product_configurator_templates.xml +++ b/addons/sale/views/sale_product_configurator_templates.xml @@ -1,23 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> <template id="optional_products_modal" name="Optional Products"> - <div role="dialog" id="modal_optional_products" class="modal fade" tabindex="-1" aria-labelledby="myModalLabel"> - <div class="modal-dialog modal-lg"> - <div class="modal-content"> - <header class="modal-header"> - <h4 class="modal-title" id="myModalLabel">Add to cart</h4> - <button type="button" class="close" data-dismiss="modal" aria-label="Close">x</button> - </header> - <div class="modal-body"> - <t t-call="sale.product_configurator_configure_optional_products" /> - </div> - <footer class="modal-footer"> - <a href="#" role="button" class="btn btn-secondary a-submit go-back js_goto_shop" aria-label="Continue shopping" title="Continue shopping"><i class="fa fa-chevron-left"></i> <span class="d-none d-md-inline">Continue Shopping</span><span class="d-inline d-md-none">Continue</span></a> - <a href="#" role="button" class="btn btn-primary float-right confirm a-submit" aria-label="Proceed to checkout" title="Proceed to checkout"><i class="fa fa-shopping-cart fa-fw"></i> <span class="d-none d-md-inline">Proceed to Checkout</span><span class="d-inline d-md-none">Checkout</span></a> - </footer> - </div> - </div> - </div> + <main class="modal-body"> + <t t-call="sale.product_configurator_configure_optional_products" /> + </main> </template> <template id="product_configurator_configure" name="Configure"> @@ -68,7 +54,7 @@ </tr> </thead> <tbody> - <tr class="js_product in_cart"> + <tr class="js_product in_cart main_product"> <td class='td-img'> <span t-field="product.image_medium" t-options='{"widget": "image" }'/> </td> @@ -136,7 +122,7 @@ <template id="optional_product_items"> <t t-foreach="product.optional_product_ids" t-as="product"> <t t-if="len(product.get_filtered_variants(reference_product)) > 0"> - <tr class="js_product"> + <tr class="js_product"> <td class="td-img"> <input type="hidden" class="product_template_id" t-att-value="product.id"/> <input type="hidden" class="product_id" t-attf-name="optional-product-#{product.id}" t-att-value="product.product_variant_id.id if len(product.product_variant_ids) == 1 else '0'"/> @@ -189,14 +175,17 @@ <t t-set="attribute_value_ids" t-value="get_attribute_value_ids(product, reference_product, pricelist)"/> <ul t-attf-class="list-unstyled js_add_cart_variants #{ul_class}" t-att-data-attribute_value_ids="json.dumps(attribute_value_ids)"> <t t-foreach="product.attribute_line_ids.sorted(key=lambda x: x.attribute_id.sequence)" t-as="variant_id"> - <li t-if="len(variant_id.product_value_ids) > 1"> + <li t-if="len(variant_id.product_value_ids) > 1" t-att-data-attribute_id="variant_id.attribute_id.id" t-att-data-attribute_name="variant_id.attribute_id.name"> <strong t-field="variant_id.attribute_id.name"/> <t t-if="variant_id.attribute_id.type == 'select'"> <select t-attf-class="form-control #{'js_variant_change' if variant_id.attribute_id.create_variant else ''}" t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"> <t t-foreach="variant_id.product_value_ids" t-as="value_id"> - <option t-att-value="value_id.id"> + <option t-att-value="value_id.id" + t-att-data-value_id="value_id.product_attribute_value_id.id" + t-att-data-value_name="value_id.name" + t-att-data-is_custom="value_id.is_custom"> <span t-field="value_id.name"/> <span t-if="value_id.price_extra"> <t t-esc="value_id.price_extra > 0 and '+' or ''"/><span t-field="value_id.price_extra" class="variant_price_extra" style="white-space: nowrap;" t-options='{ @@ -216,7 +205,15 @@ <t t-foreach="variant_id.product_value_ids" t-as="value_id"> <li class="form-group js_attribute_value" style="margin: 0;"> <label class="col-form-label" style="margin: 0 20px;"> - <input type="radio" t-att-class="'js_variant_change' if variant_id.attribute_id.create_variant else None" t-att-checked="'checked' if not inc else None" t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)" t-att-value="value_id.id" style="vertical-align: top; margin-right: 10px;"/> + <input type="radio" + t-att-class="'js_variant_change' if variant_id.attribute_id.create_variant else None" + t-att-checked="'checked' if not inc else None" + t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)" + t-att-value="value_id.id" + t-att-data-value_id="value_id.product_attribute_value_id.id" + t-att-data-value_name="value_id.name" + t-att-data-is_custom="value_id.is_custom" + style="vertical-align: top; margin-right: 10px;"/> <span t-field="value_id.name"/> <span class="badge badge-pill" t-if="value_id.price_extra"> <t t-esc="value_id.price_extra > 0 and '+' or ''"/><span t-field="value_id.price_extra" class="variant_price_extra" style="white-space: nowrap;" t-options='{ @@ -236,13 +233,16 @@ <ul class="list-inline"> <t t-set="inc" t-value="0"/> <li t-foreach="variant_id.product_value_ids" t-as="value_id" class="list-inline-item"> - <label t-attf-style="background-color:#{value_id.html_color or value_id.name}" - t-attf-class="css_attribute_color #{'active' if not inc else ''}"> + <label t-attf-style="background-color:#{value_id.html_color or value_id.name if not value_id.is_custom else ''}" + t-attf-class="css_attribute_color #{'active' if not inc else ''} #{'custom_value' if value_id.is_custom else ''}"> <input type="radio" t-att-class="'js_variant_change' if variant_id.attribute_id.create_variant else None" t-att-checked="'checked' if not inc else None" t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)" t-att-value="value_id.id" - t-att-title="value_id.name"/> + t-att-title="value_id.name" + t-att-data-value_id="value_id.product_attribute_value_id.id" + t-att-data-value_name="value_id.name" + t-att-data-is_custom="value_id.is_custom"/> </label> <t t-set="inc" t-value="inc+1"/> </li> diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index 3dc714bb33c..54c14e61797 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -423,6 +423,7 @@ 'default_description_sale': name }" /> + <field name="product_custom_variant_values" invisible="1" /> <field name="name" widget="section_and_note_text"/> <field name="product_uom_qty" diff --git a/addons/sale_management/static/tests/product_configurator_test_ui.js b/addons/sale_management/static/tests/product_configurator_test_ui.js index 7ae3a7917b0..eccc605e49f 100644 --- a/addons/sale_management/static/tests/product_configurator_test_ui.js +++ b/addons/sale_management/static/tests/product_configurator_test_ui.js @@ -1,66 +1,69 @@ -odoo.define('sale.product_configurator_tour', function(require) { - "use strict"; - - var tour = require('web_tour.tour'); - - tour.register('sale_product_configurator_tour', { - url: "/web", - test: true, - }, [{ - trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"], .oe_menu_toggler[data-menu-xmlid="sale.sale_menu_root"]' - }, { - trigger: ".o_list_button_add", - extra_trigger: ".o_sale_order" - }, { - trigger: "a:contains('Configure a product')" - }, { - trigger: '.o_product_configurator .o_input_dropdown input', - run: 'click' - }, { - trigger: 'li a:contains("Customizable Desk")', - in_modal: false, - extra_trigger: 'ul.ui-autocomplete', - run: 'click' - }, { - trigger: '.configurator_container span:contains("Aluminium")', - run: 'click' - }, { - trigger: ".o_sale_product_configurator_add", - run: 'click' - }, { - trigger: 'span:contains("Aluminium")', - extra_trigger: '.oe_optional_products_modal', - run: 'click' - }, { - trigger: '.js_product:not(.in_cart) .js_add', - extra_trigger: '.oe_optional_products_modal', - run: 'click' - }, { - trigger: '.js_product:not(.in_cart) .js_add', // not a mistake, this adds the option's option - extra_trigger: '.oe_optional_products_modal', - run: 'click' - }, { - trigger: '.a-submit:not(.js_goto_shop)', - extra_trigger: '.oe_optional_products_modal', - run: 'click' - }, - // check that 3 products were added to the SO - { - trigger: 'td.o_data_cell:contains("[FURN_0098] Customizable Desk (White, Aluminium)")', - extra_trigger: 'div[name="order_line"]', - in_modal: false, - run: function(){} - }, { - trigger: 'td.o_data_cell:contains("[E-COM13] Conference Chair (Aluminium)")', - extra_trigger: 'div[name="order_line"]', - in_modal: false, - run: function(){} - }, { - trigger: 'td.o_data_cell:contains("Chair floor protection")', - extra_trigger: 'div[name="order_line"]', - in_modal: false, - run: function(){} - }]); - - }); -
\ No newline at end of file +odoo.define('sale.product_configurator_tour', function (require) { +"use strict"; + +var tour = require('web_tour.tour'); + +tour.register('sale_product_configurator_tour', { + url: "/web", + test: true, +}, [{ + trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"], .oe_menu_toggler[data-menu-xmlid="sale.sale_menu_root"]' +}, { + trigger: ".o_list_button_add", + extra_trigger: ".o_sale_order" +}, { + trigger: "a:contains('Configure a product')" +}, { + trigger: '.o_product_configurator .o_input_dropdown input', + run: 'click' +}, { + trigger: 'li a:contains("Customizable Desk")', + in_modal: false, + extra_trigger: 'ul.ui-autocomplete', + run: 'click' +}, { + trigger: '.configurator_container span:contains("Aluminium")', + run: 'click' +}, { + trigger: ".o_sale_product_configurator_add", + run: 'click' +}, { + trigger: 'span:contains("Aluminium")', + extra_trigger: '.oe_optional_products_modal', + run: 'click' +}, { + trigger: '.js_add', + extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Conference Chair))', + run: 'click' +}, { + trigger: '.js_add', + extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Warranty))', + run: 'click' +}, { + trigger: '.js_add', + extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Chair floor protection))', + run: 'click' +}, { + trigger: 'button span:contains(Confirm)', + extra_trigger: '.oe_optional_products_modal', + run: 'click' +}, +// check that 3 products were added to the SO +{ + trigger: 'td.o_data_cell:contains("[FURN_0098] Customizable Desk (White, Aluminium)")', + extra_trigger: 'div[name="order_line"]', + in_modal: false, + run: function (){} +}, { + trigger: 'td.o_data_cell:contains("[E-COM13] Conference Chair (Aluminium)")', + extra_trigger: 'div[name="order_line"]', + in_modal: false, + run: function (){} +}, { + trigger: 'td.o_data_cell:contains("Chair floor protection")', + extra_trigger: 'div[name="order_line"]', + in_modal: false, + run: function (){} +}]); + +}); diff --git a/addons/sale_management/views/sale_order_views.xml b/addons/sale_management/views/sale_order_views.xml index 92249429cdb..0c80f4441dd 100644 --- a/addons/sale_management/views/sale_order_views.xml +++ b/addons/sale_management/views/sale_order_views.xml @@ -46,4 +46,13 @@ </p> </field> </record> + + <menuitem id="menu_product_settings" name="Products" sequence="80" + parent="sale.menu_sale_config"/> + <menuitem id="menu_product_attribute_action" + action="product.attribute_action" + parent="menu_product_settings" groups="product.group_product_variant" sequence="1"/> + <menuitem id="menu_catalog_variants_action" + action="product.variants_action" + parent="menu_product_settings" groups="product.group_product_variant" sequence="2"/> </odoo> diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index 3f57e1d85b4..140603c1afe 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -399,6 +399,7 @@ class WebsiteSale(ProductConfiguratorController): add_qty=add_qty, set_qty=set_qty, attributes=self._filter_attributes(**kw), + product_custom_variant_values=json.loads(kw.get('product_custom_variant_values')) ) return request.redirect("/shop/cart") @@ -1083,6 +1084,8 @@ class WebsiteSale(ProductConfiguratorController): attributes = self._filter_attributes(**kw) + product_custom_variant_values = json.loads(kw.get('product_custom_variant_values')) + value = {} if add_qty or set_qty: value = order._cart_update( @@ -1090,7 +1093,8 @@ class WebsiteSale(ProductConfiguratorController): add_qty=add_qty, set_qty=set_qty, attributes=attributes, - optional_product_ids=optional_product_ids + optional_product_ids=optional_product_ids, + product_custom_variant_values=self._get_custom_variant_value(int(product_id), product_custom_variant_values) ) # options have all time the same quantity @@ -1099,11 +1103,20 @@ class WebsiteSale(ProductConfiguratorController): product_id=option_id, set_qty=value.get('quantity'), attributes=attributes, - linked_line_id=value.get('line_id') + linked_line_id=value.get('line_id'), + product_custom_variant_values=self._get_custom_variant_value(option_id, product_custom_variant_values) ) return str(order.cart_quantity) + def _get_custom_variant_value(self, product_id, product_custom_variant_values): + if product_custom_variant_values: + for product_custom_variant_value in product_custom_variant_values: + if product_custom_variant_value['product_id'] == product_id: + return product_custom_variant_value['product_custom_variant_values'] + + return None + @http.route(['/product_configurator/show_optional_products_website'], type='json', auth="public", methods=['POST'], website=True) def show_optional_products_website(self, product_id, **kw): return self._show_optional_products(product_id, request.website.get_current_pricelist(), True, **kw) diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index 3d7397a7079..9170f7e3161 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -72,6 +72,8 @@ class SaleOrder(models.Model): domain = [('order_id', '=', self.id), ('product_id', '=', product_id)] if line_id: domain += [('id', '=', line_id)] + else: + domain += [('product_custom_variant_values', '=', False)] lines = self.env['sale.order.line'].sudo().search(domain) @@ -116,7 +118,7 @@ class SaleOrder(models.Model): } @api.multi - def _get_line_description(self, order_id, product_id, attributes=None): + def _get_line_description(self, order_id, product_id, attributes=None, custom_values=None): if not attributes: attributes = {} @@ -140,6 +142,9 @@ class SaleOrder(models.Model): if product.description_sale: name += '\n%s' % (product.description_sale) + if custom_values: + name += ''.join(['\n%s: %s' % (custom_value['attribute_value_name'], custom_value['custom_value']) for custom_value in custom_values]) + return name @api.multi @@ -170,7 +175,15 @@ class SaleOrder(models.Model): # Create line if no line with product_id can be located if not order_line: values = self._website_product_id_change(self.id, product_id, qty=1) - values['name'] = self._get_line_description(self.id, product_id, attributes=attributes) + + custom_values = kwargs.get('product_custom_variant_values') + if custom_values: + values['product_custom_variant_values'] = [(0, 0, { + 'attribute_value_id': custom_value['attribute_value_id'], + 'custom_value': custom_value['custom_value'] + }) for custom_value in custom_values] + + values['name'] = self._get_line_description(self.id, product_id, attributes=attributes, custom_values=custom_values) order_line = SaleOrderLineSudo.create(values) try: diff --git a/addons/website_sale/security/ir.model.access.csv b/addons/website_sale/security/ir.model.access.csv index 8c0f6569c8e..00480b5e449 100644 --- a/addons/website_sale/security/ir.model.access.csv +++ b/addons/website_sale/security/ir.model.access.csv @@ -11,6 +11,7 @@ access_product_supplierinfo,product.supplierinfo.public,product.model_product_su access_product_attribute_public,product.attribute public,product.model_product_attribute,,1,0,0,0 access_product_attribute_value_public,product.attribute value public,product.model_product_attribute_value,,1,0,0,0 access_product_product_attribute,product.product.attribute value public,product.model_product_product_attribute_value,,1,0,0,0 +access_product_product_attribute_custom_value,product.attribute.custom value,sale.model_product_attribute_custom_value,,1,0,0,0 access_product_attribute_filter_line,product.attribute filter line public,product.model_product_attribute_filter_line,,1,0,0,0 access_product_attribute_line_public,product.attribute line public,product.model_product_attribute_line,,1,0,0,0 access_fiscal_position_public,fiscal position public,account.model_account_fiscal_position,base.group_portal,1,0,0,0 diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js index 64a353de923..856efe6dc2a 100644 --- a/addons/website_sale/static/src/js/website_sale.js +++ b/addons/website_sale/static/src/js/website_sale.js @@ -124,7 +124,7 @@ odoo.define('website_sale.website_sale', function (require) { 'use strict'; var utils = require('web.utils'); -var ProductConfiguratorUtils = require('sale.product_configurator_utils'); +var ProductConfiguratorMixin = require('sale.ProductConfiguratorMixin'); var core = require('web.core'); var config = require('web.config'); var sAnimations = require('website.content.snippets.animation'); @@ -132,7 +132,7 @@ require("website.content.zoomodoo"); var _t = core._t; -sAnimations.registry.WebsiteSale = sAnimations.Class.extend({ +sAnimations.registry.WebsiteSale = sAnimations.Class.extend(ProductConfiguratorMixin, { selector: '.oe_website_sale', read_events: { 'change form .js_product:first input[name="add_qty"]': '_onChangeAddQuantity', @@ -140,7 +140,7 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend({ 'touchend .js_publish': '_onMouseupPublish', 'change .oe_cart input.js_quantity[data-product-id]': '_onChangeCartQuantity', 'click .oe_cart a.js_add_suggested_products': '_onClickSuggestedProduct', - 'click a.js_add_cart_json, button.js_add_cart_json': '_onClickAddCartJSON', + 'click a.js_add_cart_json': '_onClickAddCartJSON', 'click .a-submit': '_onClickSubmit', 'change form.js_attributes input, form.js_attributes select': '_onChangeAttribute', 'mouseup form.js_add_cart_json label': '_onMouseupAddCartLabel', @@ -162,6 +162,8 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend({ this._changeCartQuantity = _.debounce(this._changeCartQuantity.bind(this), 500); this._changeCountry = _.debounce(this._changeCountry.bind(this), 500); + + this.isWebsite = true; }, /** * @override @@ -176,9 +178,7 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend({ $('input.js_product_change', product).first().trigger('change'); }); - _.each(this.$('.js_add_cart_variants'), function (add_variant) { - $('input.js_variant_change, select.js_variant_change', add_variant).first().trigger('change'); - }); + this.triggerVariantChange(this.$el); this.$('select[name="country_id"]').change(); @@ -336,10 +336,17 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend({ /** * @private + * @param {MouseEvent} ev + */ + _onClickAddCartJSON: function (ev){ + this.onClickAddCartJSON(ev); + }, + /** + * @private * @param {Event} ev */ - _onChangeAddQuantity: function (ev, isWebsite, pricelist, no_stock_check) { - ProductConfiguratorUtils.onChangeAddQuantity(ev, true, pricelist, no_stock_check); + _onChangeAddQuantity: function (ev, no_stock_check) { + this.onChangeAddQuantity(ev, no_stock_check); }, /** * @private @@ -379,13 +386,6 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend({ * @private * @param {Event} ev */ - _onClickAddCartJSON: function (ev) { - ProductConfiguratorUtils.onClickAddCartJSON(ev); - }, - /** - * @private - * @param {Event} ev - */ _onClickSubmit: function (ev, forceSubmit) { if ($(ev.currentTarget).is('#add_to_cart, #products_grid .a-submit') && !forceSubmit) { return; @@ -447,7 +447,7 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend({ * @param {Event} ev */ _onChangeVariant: function (ev) { - ProductConfiguratorUtils.onChangeVariant(ev, false); + this.onChangeVariant(ev, false); }, /** * @private diff --git a/addons/website_sale/static/src/js/website_sale_options.js b/addons/website_sale/static/src/js/website_sale_options.js index cb9e93c204b..4ce19531af5 100644 --- a/addons/website_sale/static/src/js/website_sale_options.js +++ b/addons/website_sale/static/src/js/website_sale_options.js @@ -1,88 +1,146 @@ odoo.define('website_sale_options.website_sale', function (require) { - 'use strict'; - - var OptionalProductsModal = require('sale.OptionalProductsModal'); - var weContext = require('web_editor.context'); - var sAnimations = require('website.content.snippets.animation'); - require('website_sale.website_sale'); - - var product_name_map = {}; - var optional_products_map = {}; - - sAnimations.registry.WebsiteSaleOptions = sAnimations.Class.extend({ - selector: '.oe_website_sale', - read_events: { - 'click #add_to_cart, #products_grid .product_price .a-submit': '_onClickAdd', - }, - - /** - * @constructor - */ - init: function () { - this._super.apply(this, arguments); - - this._handleAdd = _.debounce(this._handleAdd.bind(this), 200, true); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - */ - _handleAdd: function ($form) { - var self = this; - var quantity = parseFloat($form.find('input[name="add_qty"]').val() || 1); - var product_id = parseInt($form.find('input[type="hidden"][name="product_id"], input[type="radio"][name="product_id"]:checked').first().val(),10); - - var modal = new OptionalProductsModal(quantity, product_id, $form, null, true); - - modal.on('options_empty', null, function () { - $form.trigger('submit', [true]); - }); - - modal.on('back', null, function () { - self._onModalSubmit($form); - }); - - modal.on('confirm', null, function () { - self._onModalSubmit($form, true); - }); - - modal.on('modal_ready', null, function ($modal) { - $modal.on('hidden.bs.modal', function () { - $form.removeClass('css_options'); // possibly reactivate opacity (see above) - $(this).remove(); - }); - }); - - modal.appendTo($form); - }, - - _onModalSubmit: function($form, go_to_shop){ - $form.ajaxSubmit({ - url: '/shop/cart/update_option', - data: {lang: weContext.get().lang}, - success: function (quantity) { - if (go_to_shop) { - window.location.pathname = window.location.pathname.replace(/shop([\/?].*)?$/, "shop/cart"); - } - var $q = $(".my_cart_quantity"); - $q.parent().parent().removeClass("d-none", !quantity); - $q.html(quantity).hide().fadeIn(600); +'use strict'; + +var core = require('web.core'); +var _t = core._t; +var OptionalProductsModal = require('sale.OptionalProductsModal'); +var weContext = require('web_editor.context'); +var sAnimations = require('website.content.snippets.animation'); +var ProductConfiguratorMixin = require('sale.ProductConfiguratorMixin'); +require('website_sale.website_sale'); + +sAnimations.registry.WebsiteSaleOptions = sAnimations.Class.extend(ProductConfiguratorMixin, { + selector: '.oe_website_sale', + read_events: { + 'click #add_to_cart, #products_grid .product_price .a-submit': '_onClickAdd', + }, + + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + + this._handleAdd = _.debounce(this._handleAdd.bind(this), 200, true); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Prevents the default submit to allow showing product options + * in a modal (if any) + * + * @private + * @param {MouseEvent} ev + */ + _onClickAdd: function (ev) { + ev.preventDefault(); + this._handleAdd($(ev.currentTarget).closest('form')); + }, + + /** + * Initializes the optional products modal + * and add handlers to the modal events (confirm, back, ...) + * + * @private + * @param {$.Element} $form the related webshop form + */ + _handleAdd: function ($form) { + this.$form = $form; + + var productSelector = [ + 'input[type="hidden"][name="product_id"]', + 'input[type="radio"][name="product_id"]:checked' + ]; + + this.rootProduct = { + product_id: parseInt($form.find(productSelector.join(', ')).first().val(), 10), + quantity: parseFloat($form.find('input[name="add_qty"]').val() || 1), + product_custom_variant_values: this.getCustomVariantValues($form.find('.js_product')) + }; + + this.isWebsite = true; + this.optionalProductsModal = new OptionalProductsModal($form, { + rootProduct: this.rootProduct, + isWebsite: true, + okButtonText: _t('Proceed to Checkout'), + cancelButtonText: _t('Continue Shopping'), + title: _t('Add to cart') + }).open(); + + this.optionalProductsModal.on('options_empty', null, this._onModalOptionsEmpty.bind(this)); + this.optionalProductsModal.on('confirm', null, this._onModalConfirm.bind(this)); + this.optionalProductsModal.on('back', null, this._onModalBack.bind(this)); + }, + + /** + * No optional products found for this product + * Add custom variant values in the form data and trigger submit + * + * @private + */ + _onModalOptionsEmpty: function () { + var $productCustomVariantValues = $('<input>', { + name: 'product_custom_variant_values', + type: "hidden", + value: JSON.stringify(this.rootProduct.product_custom_variant_values) + }); + this.$form.append($productCustomVariantValues); + this.$form.trigger('submit', [true]); + }, + + /** + * Submit form and stay on the same page + * + * @private + */ + _onModalBack: function () { + this._onModalSubmit(false); + }, + + /** + * Submit form and go to shop + * + * @private + */ + _onModalConfirm: function () { + this._onModalSubmit(true); + }, + + /** + * Submits the form with additional parameters + * - lang + * - product_custom_variant_values: The products custom variant values + * + * @private + * @param {Boolean} goToShop Triggers a page refresh to the url "shop/cart" + */ + _onModalSubmit: function (goToShop){ + var productCustomVariantValues = JSON.stringify( + this.optionalProductsModal.getSelectedProducts() + ); + + this.$form.ajaxSubmit({ + url: '/shop/cart/update_option', + data: { + lang: weContext.get().lang, + product_custom_variant_values: productCustomVariantValues + }, + success: function (quantity) { + if (goToShop) { + var path = window.location.pathname.replace(/shop([\/?].*)?$/, "shop/cart"); + window.location.pathname = path; } - }); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - _onClickAdd: function (ev) { - ev.preventDefault(); - this._handleAdd($(ev.currentTarget).closest('form')); - }, - }); - }); -
\ No newline at end of file + var $quantity = $(".my_cart_quantity"); + $quantity.parent().parent().removeClass("d-none", !quantity); + $quantity.html(quantity).hide().fadeIn(600); + } + }); + }, +}); + +return sAnimations.registry.WebsiteSaleOptions; + +}); diff --git a/addons/website_sale/static/src/js/website_sale_tour_buy.js b/addons/website_sale/static/src/js/website_sale_tour_buy.js index bf674dc7a47..74a307a480b 100644 --- a/addons/website_sale/static/src/js/website_sale_tour_buy.js +++ b/addons/website_sale/static/src/js/website_sale_tour_buy.js @@ -40,7 +40,7 @@ tour.register('shop_buy_product', { }, { content: "click in modal on 'Proceed to checkout' button", - trigger: 'a:contains("Proceed to Checkout")', + trigger: 'button:contains("Proceed to Checkout")', }, { content: "add suggested", diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js b/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js new file mode 100644 index 00000000000..372b64968a5 --- /dev/null +++ b/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js @@ -0,0 +1,63 @@ +odoo.define("website_sale.tour_shop", function (require) { + "use strict"; + + var tour = require("web_tour.tour"); + var base = require("web_editor.base"); + + tour.register("shop_custom_attribute_value", { + url: "/shop", + test: true, + wait_for: base.ready() + }, [{ + trigger: 'img[src*="/product.template/9"]', + run: 'click' + }, { + trigger: 'li.js_attribute_value span:contains(Custom)', + extra_trigger: 'li.js_attribute_value', + run: 'click', + }, { + trigger: 'input.variant_custom_value', + run: 'text Wood', + }, { + trigger: 'a:contains(Add to Cart)', + run: 'click', + }, { + trigger: 'div:contains(Custom: Wood)', + extra_trigger: '.js_product.in_cart.main_product', + run: function (){} // checks that Yep, it's wood! + }, { + trigger: 'button.js_add_cart_json:has(i.fa-plus)', + run: 'click', + }, { + trigger: 'div.oe_striked_price span:contains(750)', + run: function (){}, // check + }, { + trigger: 'span.oe_price span:contains(600)', + run: function (){}, // check + }, { + trigger: 'li.js_attribute_value span:contains(Aluminium)', + extra_trigger: '.oe_optional_products_modal', + run: 'click' + }, { + trigger: '.oe_price span:contains(22.9)', + run: function (){}, // check + }, { + trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Conference Chair)) .js_add', + extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Conference Chair))', + run: 'click' + }, { + trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Chair floor protection)) .js_add', + extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Chair floor protection))', + run: 'click' + }, { + trigger: 'span:contains(1,269.80)', + run: function (){}, // check + }, { + trigger: 'button:has(span:contains(Proceed to Checkout))', + run: 'click', + }, { + trigger: 'span:contains(Custom: Wood)', + extra_trigger: '#cart_products', + run: function (){}, // check + }]); +}); diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 68052b253d6..9000e28d845 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -19,7 +19,7 @@ <link rel="stylesheet" type="text/scss" href="/website_sale/static/src/scss/website_sale_options.scss"/> </xpath> <xpath expr="script[last()]" position="after"> - <script type="text/javascript" src="/sale/static/src/js/product_configurator_utils.js"></script> + <script type="text/javascript" src="/sale/static/src/js/product_configurator_mixin.js"></script> <script type="text/javascript" src="/sale/static/src/js/product_configurator_modal.js"></script> <script type="text/javascript" src="/website_sale/static/src/js/website_sale.js"></script> <script type="text/javascript" src="/website_sale/static/src/js/website_sale_utils.js"></script> @@ -28,6 +28,7 @@ <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_buy.js"></script> <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tracking.js"></script> <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_customize.js"></script> + <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js"></script> <script type="text/javascript" src="/website_sale/static/src/js/website_sale_options.js"></script> </xpath> </template> |
