summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAurélien Warnon <awa@odoo.com>2018-09-07 14:57:13 +0200
committerAurélien Warnon <awa@odoo.com>2018-09-25 18:39:32 +0200
commitdf2ea31ff6eb5978c700d0ca0d096c4b1b33bee1 (patch)
tree6a3002295cd0d9cd71dc685cb9ae432b2f7f5fb5
parentd3fbd102694bedaedac7016022a7f08d95a10553 (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
-rw-r--r--addons/product/models/product_attribute.py20
-rw-r--r--addons/sale/controllers/product_configurator.py12
-rw-r--r--addons/sale/data/sale_demo.xml28
-rw-r--r--addons/sale/models/product_product.py15
-rw-r--r--addons/sale/models/sale.py14
-rw-r--r--addons/sale/security/ir.model.access.csv1
-rw-r--r--addons/sale/static/src/js/list_editable_renderer.js71
-rw-r--r--addons/sale/static/src/js/product_configurator.js168
-rw-r--r--addons/sale/static/src/js/product_configurator_controller.js137
-rw-r--r--addons/sale/static/src/js/product_configurator_list_editable_renderer.js71
-rw-r--r--addons/sale/static/src/js/product_configurator_mixin.js437
-rw-r--r--addons/sale/static/src/js/product_configurator_modal.js297
-rw-r--r--addons/sale/static/src/js/product_configurator_renderer.js52
-rw-r--r--addons/sale/static/src/js/product_configurator_utils.js272
-rw-r--r--addons/sale/static/src/js/product_configurator_view.js20
-rw-r--r--addons/sale/static/src/scss/product_configurator.scss16
-rw-r--r--addons/sale/static/tests/product_configurator.test.js48
-rw-r--r--addons/sale/views/assets.xml8
-rw-r--r--addons/sale/views/product_attribute_views.xml3
-rw-r--r--addons/sale/views/sale_product_configurator_templates.xml50
-rw-r--r--addons/sale/views/sale_views.xml1
-rw-r--r--addons/sale_management/static/tests/product_configurator_test_ui.js135
-rw-r--r--addons/sale_management/views/sale_order_views.xml9
-rw-r--r--addons/website_sale/controllers/main.py17
-rw-r--r--addons/website_sale/models/sale_order.py17
-rw-r--r--addons/website_sale/security/ir.model.access.csv1
-rw-r--r--addons/website_sale/static/src/js/website_sale.js32
-rw-r--r--addons/website_sale/static/src/js/website_sale_options.js230
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_buy.js2
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js63
-rw-r--r--addons/website_sale/views/templates.xml3
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>