[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
This commit is contained in:
Aurélien Warnon
2018-09-25 18:39:32 +02:00
parent d3fbd10269
commit df2ea31ff6
31 changed files with 1390 additions and 848 deletions
+14 -6
View File
@@ -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()
@@ -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)
+25 -3
View File
@@ -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
-->
+13 -2
View File
@@ -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")
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')
+12 -2
View File
@@ -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()
+1
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
42 access_product_attribute_sale_manager product.attribute manager product.model_product_attribute sales_team.group_sale_manager 1 1 1 1
43 access_product_attribute_value_sale_manager product.attribute manager value product.model_product_attribute_value sales_team.group_sale_manager 1 1 1 1
44 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
45 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
46 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
47 access_product_attribute_line_sale_manager product.attribute manager line product.model_product_attribute_line sales_team.group_sale_manager 1 1 1 1
48 access_account_tax_sale_manager account.tax sale manager account.model_account_tax sales_team.group_sale_salesman 1 0 0 0
@@ -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
}
});
}
});
});
@@ -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;
});
@@ -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;
});
@@ -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
}
});
}
});
});
@@ -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;
});
@@ -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");
self.$modal = $modal;
self._addModalEvents();
} else {
self.trigger('options_empty');
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;
});
$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;
});
@@ -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;
});
@@ -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
};
});
@@ -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;
});
@@ -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 {
@@ -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);
+5 -3
View File
@@ -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>
@@ -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>
@@ -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>
+1
View File
@@ -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"
@@ -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(){}
}]);
});
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 (){}
}]);
});
@@ -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>
+15 -2
View File
@@ -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)
+15 -2
View File
@@ -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:
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
11 access_product_attribute_public product.attribute public product.model_product_attribute 1 0 0 0
12 access_product_attribute_value_public product.attribute value public product.model_product_attribute_value 1 0 0 0
13 access_product_product_attribute product.product.attribute value public product.model_product_product_attribute_value 1 0 0 0
14 access_product_product_attribute_custom_value product.attribute.custom value sale.model_product_attribute_custom_value 1 0 0 0
15 access_product_attribute_filter_line product.attribute filter line public product.model_product_attribute_filter_line 1 0 0 0
16 access_product_attribute_line_public product.attribute line public product.model_product_attribute_line 1 0 0 0
17 access_fiscal_position_public fiscal position public account.model_account_fiscal_position base.group_portal 1 0 0 0
@@ -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();
@@ -334,12 +334,19 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend({
// Handlers
//--------------------------------------------------------------------------
/**
* @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
@@ -375,13 +382,6 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend({
_onClickSuggestedProduct: function (ev) {
$(ev.currentTarget).prev('input').val(1).trigger('change');
},
/**
* @private
* @param {Event} ev
*/
_onClickAddCartJSON: function (ev) {
ProductConfiguratorUtils.onClickAddCartJSON(ev);
},
/**
* @private
* @param {Event} ev
@@ -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
@@ -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);
'use strict';
var modal = new OptionalProductsModal(quantity, product_id, $form, null, true);
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');
modal.on('options_empty', null, function () {
$form.trigger('submit', [true]);
});
sAnimations.registry.WebsiteSaleOptions = sAnimations.Class.extend(ProductConfiguratorMixin, {
selector: '.oe_website_sale',
read_events: {
'click #add_to_cart, #products_grid .product_price .a-submit': '_onClickAdd',
},
modal.on('back', null, function () {
self._onModalSubmit($form);
});
/**
* @constructor
*/
init: function () {
this._super.apply(this, arguments);
modal.on('confirm', null, function () {
self._onModalSubmit($form, true);
});
this._handleAdd = _.debounce(this._handleAdd.bind(this), 200, 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);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
_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);
/**
* 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'));
},
});
});
var $quantity = $(".my_cart_quantity");
$quantity.parent().parent().removeClass("d-none", !quantity);
$quantity.html(quantity).hide().fadeIn(600);
}
});
},
});
return sAnimations.registry.WebsiteSaleOptions;
});
@@ -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",
@@ -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
}]);
});
+2 -1
View File
@@ -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>