[IMP] website_sale, *: allow changing qty of optional products

* sale, sale_management, sale_product_configurator,
  website_sale_product_configurator

1. Product Configurator
-----------------------

- It is now possible to change the quantity of an optional product

- Default optional product quantity is set to its parent product
  current quantity.

- The order in which optional products of an option appear when added
  to the cart is fixed it will now be displayed below the correct
  product even if there are multiple options with the same product
  template id.
  To achieve this a unique id has been added to the products and
  a reference to their parent unique id.

- The way products are given to the backend when configuration is done
  has been improved, now only an array with the product and useful info
  is submitted.

2. eCommerce in cart review
---------------------------

- It is now possible to change the quantity of an accessory product.

- It is now possible to delete an optional product added through the
  product configurator.

- The labels "Option: optional product name" below the parent product
  are now removed when a related optional product is deleted.

- The labels "Option: optional product name" are now displayed below
  the correct product.

task-1951560

closes odoo/odoo#33002

Signed-off-by: Quentin Smetz (qsm) <qsm@odoo.com>
This commit is contained in:
fja-odoo
2019-09-06 12:13:40 +00:00
committed by qsm-odoo
parent d4404de7c2
commit d7a0b59068
13 changed files with 250 additions and 244 deletions
+5 -22
View File
@@ -33,49 +33,32 @@ var VariantMixin = {
* are not relevant
*
* @param {MouseEvent} ev
* @param {$.Element} [params.$container] force the used container
*/
onChangeVariant: function (ev, params) {
onChangeVariant: function (ev) {
var $parent = $(ev.target).closest('.js_product');
if (!$parent.data('uniqueId')) {
$parent.data('uniqueId', _.uniqueId());
}
this._throttledGetCombinationInfo($parent.data('uniqueId'))(ev, params);
this._throttledGetCombinationInfo($parent.data('uniqueId'))(ev);
},
/**
* @see onChangeVariant
*
* @private
* @param {Event} ev
* @param {$.Element} [params.$container] force the used container
* @returns {Deferred}
*/
_getCombinationInfo: function (ev, params) {
_getCombinationInfo: function (ev) {
var self = this;
if ($(ev.target).hasClass('variant_custom_value')) {
return Promise.resolve();
}
var $component;
if (params && params.$container) {
$component = params.$container;
} else if ($(ev.currentTarget).closest('form').length > 0){
$component = $(ev.currentTarget).closest('form');
} else if ($(ev.currentTarget).closest('.oe_optional_products_modal').length > 0){
$component = $(ev.currentTarget).closest('.oe_optional_products_modal');
} else if ($(ev.currentTarget).closest('.o_product_configurator').length > 0) {
$component = $(ev.currentTarget).closest('.o_product_configurator');
} else {
$component = $(ev.currentTarget);
}
var qty = $component.find('input[name="add_qty"]').val();
var $parent = $(ev.target).closest('.js_product');
var qty = $parent.find('input[name="add_qty"]').val();
var combination = this.getSelectedVariantValues($parent);
var parentCombination = $parent.find('ul[data-attribute_exclusions]').data('attribute_exclusions').parent_combination;
var productTemplateId = parseInt($parent.find('.product_template_id').val());
self._checkExclusions($parent, combination);
@@ -195,7 +178,7 @@ var VariantMixin = {
*/
triggerVariantChange: function ($container) {
var self = this;
$container.find('ul[data-attribute_exclusions]').trigger('change', {$container: $container});
$container.find('ul[data-attribute_exclusions]').trigger('change');
$container.find('input.js_variant_change:checked, select.js_variant_change').each(function () {
self.handleCustomValues($(this));
});
@@ -27,10 +27,6 @@
}
.css_not_available.js_product {
> *:nth-child(4) > * {
display: none;
}
.css_quantity,
.product_price {
display: none;
@@ -79,7 +79,7 @@
<xpath expr="//section[@id='details']//div[@id='quote_qty']" position="replace">
<t t-if="sale_order.state in ['draft', 'sent'] and line.sale_order_option_ids">
<div class="input-group oe_website_spinner w-50 pull-right">
<div class="input-group w-50 pull-right">
<div class="input-group-prepend d-print-none">
<span class="input-group-text">
@@ -6,13 +6,11 @@ var Dialog = require('web.Dialog');
var ServicesMixin = require('web.ServicesMixin');
var VariantMixin = require('sale.VariantMixin');
var optionalProductsMap = {};
var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
events: _.extend({}, Dialog.prototype.events, VariantMixin.events, {
'click a.js_add, a.js_remove': '_onAddOrRemoveOption',
'click button.js_add_cart_json': 'onClickAddCartJSON',
'change .in_cart.main_product input.js_quantity': '_onChangeQuantity',
'change .in_cart input.js_quantity': '_onChangeQuantity',
'change .js_raw_price': '_computePriceTotal'
}),
/**
@@ -61,8 +59,6 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
this.previousModalHeight = params.previousModalHeight;
this.dialogClass = 'oe_optional_products_modal';
this._productImageField = 'image_128';
// reset any previously populated properties maps
optionalProductsMap = {};
this._opened.then(function () {
if (self.previousModalHeight) {
@@ -89,7 +85,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
}
})
.then(function (modalContent) {
if (modalContent){
if (modalContent) {
var $modalContent = $(modalContent);
$modalContent = self._postProcessContent($modalContent);
self.$content = $modalContent;
@@ -115,7 +111,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
var self = this;
this.appendTo($('<div/>')).then(function () {
if (!self.preventOpening){
if (!self.preventOpening) {
self.$modal.find(".modal-body").replaceWith(self.$el);
self.$modal.attr('open', true);
self.$modal.removeAttr("aria-hidden");
@@ -141,6 +137,20 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
this.$el.find('input[name="add_qty"]').val(this.rootProduct.quantity);
// set a unique id to each row for options hierarchy
var $products = this.$el.find('tr.js_product');
_.each($products, function (el) {
var $el = $(el);
var uniqueId = self._getUniqueId(el);
var productId = parseInt($el.find('input.product_id').val(), 10);
if (productId === self.rootProduct.product_id) {
self.rootProduct.unique_id = uniqueId;
} else {
el.dataset.parentUniqueId = self.rootProduct.unique_id;
}
});
return def.then(function () {
// This has to be triggered to compute the "out of stock" feature
self._opened.then(function () {
@@ -167,28 +177,21 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
getSelectedProducts: function () {
var self = this;
var products = [this.rootProduct];
this.$modal.find('.js_product.in_cart:not(.main_product)').each(function (){
var quantity = 0;
this.$modal.find('.js_product.in_cart:not(.main_product)').each(function () {
var $item = $(this);
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()
);
}
var quantity = parseInt($item.find('input[name="add_qty"]').val(), 10);
var parentUniqueId = this.dataset.parentUniqueId;
var uniqueId = this.dataset.uniqueId;
var productCustomVariantValues = self.getCustomVariantValues($(this));
var noVariantAttributeValues = self.getNoVariantAttributeValues($(this));
products.push({
product_id: parseInt($item.find('input.product_id').val()),
product_template_id: parseInt($item.find('input.product_template_id').val()),
quantity: quantity,
product_custom_attribute_values: productCustomVariantValues,
no_variant_attribute_values: noVariantAttributeValues
'product_id': parseInt($item.find('input.product_id').val(), 10),
'product_template_id': parseInt($item.find('input.product_template_id').val(), 10),
'quantity': quantity,
'parent_unique_id': parentUniqueId,
'unique_id': uniqueId,
'product_custom_attribute_values': productCustomVariantValues,
'no_variant_attribute_values': noVariantAttributeValues
});
});
@@ -219,12 +222,12 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
.find('td.td-product_name div.text-muted.small');
var description = $productDescription.html();
$.each(this.rootProduct.product_custom_attribute_values, function (){
$.each(this.rootProduct.product_custom_attribute_values, function () {
description += '<br/>' + this.attribute_value_name + ': ' + this.custom_value;
});
$.each(this.rootProduct.no_variant_attribute_values, function (){
if (this.is_custom !== 'True'){
$.each(this.rootProduct.no_variant_attribute_values, function () {
if (this.is_custom !== 'True') {
description += '<br/>' + this.attribute_name + ': ' + this.attribute_value_name;
}
});
@@ -264,21 +267,20 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
* @private
* @param {MouseEvent} ev
*/
_onAddOrRemoveOption: function (ev){
_onAddOrRemoveOption: function (ev) {
ev.preventDefault();
var self = this;
var $target = $(ev.currentTarget);
var $modal = $target.parents('.oe_optional_products_modal');
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($target.hasClass("js_add") ? 1 : 0 );
$parent.find(".js_remove");
var productTemplateId = $parent.find(".product_template_id").val();
if ($target.hasClass('js_add')) {
self._onAddOption($modal, $parent ,productTemplateId);
self._onAddOption($modal, $parent, productTemplateId);
} else {
self._onRemoveOption($modal, $parent ,productTemplateId);
self._onRemoveOption($modal, $parent);
}
self._computePriceTotal();
@@ -293,13 +295,14 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
*/
_onAddOption: function ($modal, $parent, productTemplateId) {
var self = this;
var $main_product = $modal.find('.js_product:first');
var $selectOptionsText = $modal.find('.o_select_options');
// 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');
$parent.find('.td-qty').append($parent.find('.optional_product_quantity'));
var parentUniqueId = $parent[0].dataset.parentUniqueId;
var $optionParent = $modal.find('tr.js_product[data-unique-id="' + parentUniqueId + '"]');
// remove attribute values selection and update + show quantity input
$parent.find('.td-product_name').removeAttr("colspan");
$parent.find('.td-qty').removeClass('d-none');
var productCustomVariantValues = self.getCustomVariantValues($parent);
var noVariantAttributeValues = self.getNoVariantAttributeValues($parent);
@@ -308,12 +311,12 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
.find('td.td-product_name div.float-left');
var description = '';
$.each(productCustomVariantValues, function (){
$.each(productCustomVariantValues, function () {
description += '<br/>' + this.attribute_value_name + ': ' + this.custom_value;
});
$.each(noVariantAttributeValues, function (){
if (this.is_custom !== 'True'){
$.each(noVariantAttributeValues, function () {
if (this.is_custom !== 'True') {
description += '<br/>' + this.attribute_name + ': ' + this.attribute_value_name;
}
});
@@ -326,25 +329,14 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
$productDescription.append($customAttributeValuesDescription);
}
// if it's an optional product of an optional product, place it after it's parent
var parentProductId = null;
for (var productIdKey in optionalProductsMap) {
if (optionalProductsMap[productIdKey].indexOf(productTemplateId) !== -1) {
parentProductId = productIdKey;
break;
}
$('tr:last').after($parent);
}
if (parentProductId) {
$modal.find('.product_template_id').filter(function () {
return parentProductId === $(this).val();
}).parents('.js_product:first').after($parent);
} else {
// else, place it after the main product
$main_product.after($parent);
// place it after its parent and its parent options
var $tmpOptionParent = $optionParent;
while ($tmpOptionParent.length) {
$optionParent = $tmpOptionParent;
$tmpOptionParent = $modal.find('tr.js_product.in_cart[data-parent-unique-id="' + $optionParent[0].dataset.uniqueId + '"]').last();
}
$optionParent.after($parent);
$parent.addClass('in_cart');
this.selectOrCreateProduct(
$parent,
@@ -364,19 +356,18 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
self.$el.find('input[name="add_qty"]').trigger('change');
self.triggerVariantChange($addedItem);
if ($addedItem.find(".product_template_id").length > 0) {
// we need to map this product with it's optional products to be able to:
// - remove them from the cart if the parent product is removed
// - place the optional products under their parent in
// the interface when they're added to the cart
optionalProductsMap[productTemplateId] = $addedItem
.find(".product_template_id")
.map(function () {
return $(this).val();
}).get();
}
// add a unique id to the new products
var parentUniqueId = $parent[0].dataset.uniqueId;
var parentQty = $parent.find('input[name="add_qty"]').val();
$addedItem.filter('.js_product').each(function () {
var $el = $(this);
var uniqueId = self._getUniqueId(this);
this.dataset.uniqueId = uniqueId;
this.dataset.parentUniqueId = parentUniqueId;
$el.find('input[name="add_qty"]').val(parentQty);
});
if ($selectOptionsText.nextAll('.js_product').length === 0){
if ($selectOptionsText.nextAll('.js_product').length === 0) {
// no more optional products to select -> hide the header
$selectOptionsText.hide();
}
@@ -389,19 +380,21 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
* @see _onAddOrRemoveOption
* @param {$.Element} $modal
* @param {$.Element} $parent
* @param {integer} productTemplateId
*/
_onRemoveOption: function ($modal, $parent, productTemplateId){
_onRemoveOption: function ($modal, $parent) {
// restore attribute values selection
var uniqueId = $parent[0].dataset.parentUniqueId;
var qty = $modal.find('tr.js_product.in_cart[data-unique-id="' + uniqueId + '"]').find('input[name="add_qty"]').val();
$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('.td-product_name').attr("colspan", 2);
$parent.find('.td-qty').addClass('d-none');
$parent.find('input[name="add_qty"]').val(qty);
$parent.find('.custom_attribute_values_description').remove();
var $select_options_text = $modal.find('.o_select_options');
$select_options_text.show();
$modal.find('.o_select_options').show();
this._removeOptionOption($modal, productTemplateId);
var productUniqueId = $parent[0].dataset.uniqueId;
this._removeOptionOption($modal, productUniqueId);
$modal.find('tr:last').after($parent);
},
@@ -411,26 +404,20 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
*
* @private
* @param {$.Element} $modal
* @param {integer} optionId The removed optional product id
* @param {integer} optionUniqueId The removed optional product id
*/
_removeOptionOption: function ($modal, optionId) {
_removeOptionOption: function ($modal, optionUniqueId) {
var self = this;
if (optionalProductsMap[optionId]) {
optionalProductsMap[optionId].forEach(function (productId) {
$modal.find('.product_template_id').filter(function () {
return productId === $(this).val();
}).parents('.js_product:first').remove();
self._removeOptionOption($modal, productId);
});
delete optionalProductsMap[optionId];
}
$modal.find('tr.js_product[data-parent-unique-id="' + optionUniqueId + '"]').each(function () {
var uniqueId = this.dataset.uniqueId;
$(this).remove();
self._removeOptionOption($modal, uniqueId);
});
},
/**
* @override
*/
_onChangeCombination:function (ev, $parent, combination) {
_onChangeCombination: function (ev, $parent, combination) {
$parent
.find('.td-product_name .product-name')
.first()
@@ -441,31 +428,28 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
this._computePriceTotal();
},
/**
* When the quantity of the root product is updated, we need to update
* the quantity of all the selected optional products.
* Update price total when the quantity of a product is changed
*
* @private
* @param {MouseEvent} ev
*/
_onChangeQuantity: function (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');
} else {
$modal.find(".js_items").removeClass('d-none').addClass('add_qty').text(qty);
$modal.find(".js_item").addClass('d-none').removeClass('add_qty');
}
_onChangeQuantity: function (ev) {
var $product = $(ev.target.closest('tr.js_product'));
var qty = parseFloat($(ev.currentTarget).val());
this.rootProduct.quantity = qty;
var uniqueId = $product[0].dataset.uniqueId;
this.$el.find('tr.js_product:not(.in_cart)[data-parent-unique-id="' + uniqueId + '"] input[name="add_qty"]').each(function () {
$(this).val(qty);
});
if (this._triggerPriceUpdateOnChangeQuantity()){
if (this._triggerPriceUpdateOnChangeQuantity()) {
this.onChangeAddQuantity(ev);
}
this.trigger('update_quantity', qty);
if ($product.hasClass('main_product')) {
this.rootProduct.quantity = qty;
}
this.trigger('update_quantity', this.rootProduct.quantity);
this._computePriceTotal();
},
/**
@@ -473,10 +457,10 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
* we need to refresh the total price row
*/
_computePriceTotal: function () {
if (this.$modal.find('.js_price_total').length){
if (this.$modal.find('.js_price_total').length) {
var price = 0;
var quantity = parseInt(this.$modal.find('input[name="add_qty"]').first().val());
this.$modal.find('.js_product.in_cart').each(function (){
this.$modal.find('.js_product.in_cart').each(function () {
var quantity = parseInt($(this).find('input[name="add_qty"]').first().val(), 10);
price += parseFloat($(this).find('.js_raw_price').html()) * quantity;
});
@@ -493,7 +477,20 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
*/
_triggerPriceUpdateOnChangeQuantity: function () {
return true;
}
},
/**
* Returns a unique id for `$el`.
*
* @private
* @param {Element} el
* @returns {integer}
*/
_getUniqueId: function (el) {
if (!el.dataset.uniqueId) {
el.dataset.uniqueId = parseInt(_.uniqueId(), 10);
}
return el.dataset.uniqueId;
},
});
return OptionalProductsModal;
@@ -19,33 +19,61 @@ tour.register('sale_product_configurator_optional_products_tour', {
trigger: "a:contains('Add a product')"
}, {
trigger: 'div[name="product_template_id"] input',
run: function (){
run: function () {
var $input = $('div[name="product_template_id"] input');
$input.click();
$input.val('Office Chair Black');
$input.val('Customizable Desk');
var keyDownEvent = jQuery.Event("keydown");
keyDownEvent.which = 42;
$input.trigger(keyDownEvent);
}
}, {
trigger: 'ul.ui-autocomplete a:contains("Office Chair Black")',
trigger: 'ul.ui-autocomplete a:contains("Customizable Desk")',
run: 'click'
}, {
trigger: '.js_add'
trigger: '.o_sale_product_configurator_add'
}, {
trigger: '.js_add_cart_json .fa-plus'
trigger: 'tr:has(.td-product_name:contains("Office Chair Black")) .js_add',
}, {
trigger: 'tr:has(.td-product_name:contains("Customizable Desk")) .fa-plus'
}, {
trigger: 'tr:has(.td-product_name:contains("Chair floor protection")) .js_add',
}, {
content: 'Is below its parent 1',
trigger: 'tr:has(.td-product_name:contains("Office Chair Black")) + tr:has(.td-product_name:contains("Chair floor protection"))'
}, {
trigger: 'tr:has(.td-product_name:contains("Conference Chair")) .js_add',
}, {
trigger: 'tr:has(.td-product_name:contains("Conference Chair")) .fa-minus'
}, {
trigger: 'tr:has(.td-product_name:contains("Chair floor protection")) .js_add',
}, {
content: 'Is below its parent 2',
trigger: 'tr:has(.td-product_name:contains("Conference Chair")) + tr:has(.td-product_name:contains("Chair floor protection"))'
}, {
trigger: 'button span:contains(Confirm)',
extra_trigger: '.oe_optional_products_modal',
run: 'click'
}, {
trigger: 'tr:has(td.o_data_cell:contains("Office Chair Black")) td.o_data_cell:contains("2.0")',
trigger: 'tr:has(td.o_data_cell:contains("Customizable Desk")) td.o_data_cell:contains("2.0")',
extra_trigger: 'div[name="order_line"]',
run: function (){} // check added product
run: function () {}, // check added product
}, {
trigger: 'tr:has(td.o_data_cell:contains("Chair floor protection")) td.o_data_cell:contains("2.0")',
trigger: 'tr:has(td.o_data_cell:contains("Office Chair Black")) td.o_data_cell:contains("1.0")',
extra_trigger: 'div[name="order_line"]',
run: function (){} // check added product
run: function () {}, // check added product
}, {
trigger: 'tr:has(td.o_data_cell:contains("Conference Chair")) td.o_data_cell:contains("1.0")',
extra_trigger: 'div[name="order_line"]',
run: function () {}, // check added product
}, {
trigger: 'tr:has(td.o_data_cell:contains("Chair floor protection")):nth(0) td.o_data_cell:contains("1.0")',
extra_trigger: 'div[name="order_line"]',
run: function () {}, // check added product
}, {
trigger: 'tr:has(td.o_data_cell:contains("Chair floor protection")):nth(1) td.o_data_cell:contains("1.0")',
extra_trigger: 'div[name="order_line"]',
run: function () {}, // check added product
}]);
});
@@ -160,10 +160,13 @@ class TestUi(odoo.tests.HttpCase):
if the main product does not have variants.
"""
# add an optional product to the office chair for test purposes
# add an optional product to the office chair and the custo desk for testing purposes
office_chair = self.env.ref('product.product_product_12')
custo_desk = self.env.ref('product.product_product_4')
office_chair.update({
'optional_product_ids': [(6, 0, [self.env.ref('sale_product_configurator.product_product_1_product_template').id])]
})
custo_desk.update({
'optional_product_ids': [(6, 0, [self.env.ref('product.product_product_12_product_template').id, self.env.ref('product.product_product_11_product_template').id])]
})
self.start_tour("/web", 'sale_product_configurator_optional_products_tour', login="admin")
@@ -37,7 +37,7 @@
"display_currency": (pricelist or product).currency_id
}'/>
</h2>
<div class="css_quantity input-group oe_website_spinner">
<div class="css_quantity input-group">
<div class="input-group-prepend">
<button t-attf-href="#" class="btn btn-primary js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one">
<i class="fa fa-minus"></i>
@@ -98,7 +98,7 @@
</div>
</td>
<td class="text-center td-qty">
<div class="css_quantity input-group oe_website_spinner">
<div class="css_quantity input-group">
<div class="input-group-prepend">
<button t-attf-href="#" class="btn btn-primary js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one">
<i class="fa fa-minus"></i>
@@ -177,6 +177,21 @@
<t t-call="sale.variants"/>
</div>
</td>
<td class="text-center td-qty d-none">
<div class="css_quantity input-group">
<div class="input-group-prepend">
<button t-attf-href="#" class="btn btn-primary float_left js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one">
<i class="fa fa-minus"></i>
</button>
</div>
<input type="text" class="js_quantity form-control quantity" data-min="1" name="add_qty" t-att-value="add_qty or 1"/>
<div class="input-group-append">
<button t-attf-href="#" class="btn btn-primary float_left js_add_cart_json d-none d-md-inline-block" aria-label="Add one" title="Add one">
<i class="fa fa-plus"></i>
</button>
</div>
</div>
</td>
<td class="text-center td-price">
<div t-attf-class="text-danger oe_default_price oe_optional oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
t-esc="combination_info['list_price']"
@@ -193,10 +208,8 @@
<span class="js_raw_price d-none" t-esc="combination_info['price']" />
<p class="css_not_available_msg alert alert-warning">Option not available</p>
<input type="hidden" class="js_optional_same_quantity" t-attf-name="optional-add-#{product.id}" value="0"/>
<a role="button" href="#" class="js_add btn btn-primary btn-sm"><i class="fa fa-shopping-cart add-optionnal-item"></i> Add to cart</a>
<span class="js_remove d-none">
<span class="optional_product_quantity"><span class="js_item add_qty">1 </span><span class="js_items d-none">5 </span> <span t-field="product.uom_id.sudo().name"/><br/></span>
<a role="button" href="#" class="js_remove"><i class="fa fa-trash-o remove-optionnal-item"></i></a>
</span>
</td>
+26 -13
View File
@@ -147,6 +147,9 @@ class SaleOrder(models.Model):
product_context = dict(self.env.context)
product_context.setdefault('lang', self.sudo().partner_id.lang)
SaleOrderLineSudo = self.env['sale.order.line'].sudo().with_context(product_context)
# change lang to get correct name of attributes/values
product_with_context = self.env['product.product'].with_context(product_context)
product = product_with_context.browse(int(product_id))
try:
if add_qty:
@@ -168,9 +171,6 @@ class SaleOrder(models.Model):
# Create line if no line with product_id can be located
if not order_line:
# change lang to get correct name of attributes/values
product = self.env['product.product'].with_context(product_context).browse(int(product_id))
if not product:
raise UserError(_("The given product does not exist therefore it cannot be added to cart."))
@@ -223,11 +223,6 @@ class SaleOrder(models.Model):
# create the line
order_line = SaleOrderLineSudo.create(values)
# Generate the description with everything. This is done after
# creating because the following related fields have to be set:
# - product_no_variant_attribute_value_ids
# - product_custom_attribute_value_ids
order_line.name = order_line.get_sale_order_line_multiline_description_sale(product)
try:
order_line._compute_tax_id()
@@ -245,7 +240,12 @@ class SaleOrder(models.Model):
# Remove zero of negative lines
if quantity <= 0:
linked_line = order_line.linked_line_id
order_line.unlink()
if linked_line:
# update description of the parent
linked_product = product_with_context.browse(linked_line.product_id.id)
linked_line.name = linked_line.get_sale_order_line_multiline_description_sale(linked_product)
else:
# update line
no_variant_attributes_price_extra = [ptav.price_extra for ptav in order_line.product_no_variant_attribute_value_ids]
@@ -258,7 +258,8 @@ class SaleOrder(models.Model):
'date': order.date_order,
'pricelist': order.pricelist_id.id,
})
product = self.env['product.product'].with_context(product_context).browse(product_id)
product_with_context = self.env['product.product'].with_context(product_context)
product = product_with_context.browse(product_id)
values['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(
order_line._get_display_price(product),
order_line.product_id.taxes_id,
@@ -273,13 +274,17 @@ class SaleOrder(models.Model):
linked_line = SaleOrderLineSudo.browse(kwargs['linked_line_id'])
order_line.write({
'linked_line_id': linked_line.id,
'name': order_line.name + "\n" + _("Option for:") + ' ' + linked_line.product_id.display_name,
})
linked_line.write({"name": linked_line.name + "\n" + _("Option:") + ' ' + order_line.product_id.display_name})
linked_product = product_with_context.browse(linked_line.product_id.id)
linked_line.name = linked_line.get_sale_order_line_multiline_description_sale(linked_product)
# Generate the description with everything. This is done after
# creating because the following related fields have to be set:
# - product_no_variant_attribute_value_ids
# - product_custom_attribute_value_ids
# - linked_line_id
order_line.name = order_line.get_sale_order_line_multiline_description_sale(product)
option_lines = self.order_line.filtered(lambda l: l.linked_line_id.id == order_line.id)
for option_line_id in option_lines:
self._cart_update(option_line_id.product_id.id, option_line_id.id, add_qty, set_qty, **kwargs)
return {'line_id': order_line.id, 'quantity': quantity, 'option_ids': list(set(option_lines.ids))}
@@ -357,6 +362,14 @@ class SaleOrderLine(models.Model):
linked_line_id = fields.Many2one('sale.order.line', string='Linked Order Line', domain="[('order_id', '!=', order_id)]", ondelete='cascade')
option_line_ids = fields.One2many('sale.order.line', 'linked_line_id', string='Options Linked')
def get_sale_order_line_multiline_description_sale(self, product):
description = super(SaleOrderLine, self).get_sale_order_line_multiline_description_sale(product)
if self.linked_line_id:
description += "\n" + _("Option for: %s") % self.linked_line_id.product_id.display_name
if self.option_line_ids:
description += "\n" + '\n'.join([_("Option: %s") % option_line.product_id.display_name for option_line in self.option_line_ids])
return description
@api.depends('product_id.display_name')
def _compute_name_short(self):
""" Compute a short name for this sale order line, to be used on the website where we don't have much space.
@@ -706,7 +706,7 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, {
*
* @override
*/
onChangeVariant: function (ev, data) {
onChangeVariant: function (ev) {
var $component = $(ev.currentTarget).closest('.js_product');
$component.find('input').each(function () {
var $el = $(this);
+2 -5
View File
@@ -616,7 +616,7 @@
<template id="product_quantity" inherit_id="website_sale.product" customize_show="True" name="Select Quantity">
<xpath expr="//a[@id='add_to_cart']" position="before">
<div class="css_quantity input-group oe_website_spinner" contenteditable="false">
<div class="css_quantity input-group" contenteditable="false">
<div class="input-group-prepend">
<a t-attf-href="#" class="btn btn-secondary js_add_cart_json" aria-label="Remove one" title="Remove one">
<i class="fa fa-minus"></i>
@@ -857,7 +857,7 @@
</t>
</td>
<td class="text-center td-qty">
<div t-if="not line.linked_line_id" class="css_quantity input-group mx-auto oe_website_spinner">
<div class="css_quantity input-group mx-auto">
<div class="input-group-prepend">
<a t-attf-href="#" class="btn btn-link js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one">
<i class="fa fa-minus"></i>
@@ -870,9 +870,6 @@
</a>
</div>
</div>
<t t-if="line.linked_line_id">
<span class="js_quantity text-muted" t-att-data-line-id="line.id" t-att-data-product-id="line.product_id.id" t-esc="int(line.product_uom_qty)"/>
</t>
</td>
<td class="text-center td-price" name="price">
<t t-set="combination" t-value="line.product_id.product_template_attribute_value_ids + line.product_no_variant_attribute_value_ids"/>
@@ -24,7 +24,6 @@ class WebsiteSaleProductConfiguratorController(ProductConfiguratorController):
kw.pop('pricelist_id')
return self.optional_product_items(product_id, request.website.get_current_pricelist(), **kw)
class WebsiteSale(WebsiteSale):
def _prepare_product_values(self, product, category, search, **kwargs):
values = super(WebsiteSale, self)._prepare_product_values(product, category, search, **kwargs)
@@ -33,8 +32,22 @@ class WebsiteSale(WebsiteSale):
return values
@http.route(['/shop/cart/update_option'], type='http', auth="public", methods=['POST'], website=True, multilang=False)
def cart_options_update_json(self, product_id, add_qty=1, set_qty=0, goto_shop=None, lang=None, **kw):
"""This route is called when submitting the optional product modal."""
def cart_options_update_json(self, product_and_options, goto_shop=None, lang=None, **kwargs):
"""This route is called when submitting the optional product modal.
The product without parent is the main product, the other are options.
Options need to be linked to their parents with a unique ID.
The main product is the first product in the list and the options
need to be right after their parent.
product_and_options {
'product_id',
'product_template_id',
'quantity',
'parent_unique_id',
'unique_id',
'product_custom_attribute_values',
'no_variant_attribute_values'
}
"""
if lang:
request.website = request.website.with_context(lang=lang)
@@ -42,58 +55,29 @@ class WebsiteSale(WebsiteSale):
if order.state != 'draft':
request.session['sale_order_id'] = None
order = request.website.sale_get_order(force_create=True)
optional_product_ids = []
for k, v in kw.items():
if "optional-product-" in k and int(kw.get(k.replace("product", "add"))):
optional_product_ids.append(int(v))
custom_values = []
if kw.get('custom_values'):
custom_values = json.loads(kw.get('custom_values'))
value = {}
if add_qty or set_qty:
product_and_options = json.loads(product_and_options)
if product_and_options:
# The main product is the first, optional products are the rest
main_product = product_and_options[0]
value = order._cart_update(
product_id=int(product_id),
add_qty=add_qty,
set_qty=set_qty,
optional_product_ids=optional_product_ids,
product_custom_attribute_values=self._get_product_custom_value(
int(product_id),
custom_values,
'product_custom_attribute_values'
),
no_variant_attribute_values=self._get_product_custom_value(
int(product_id),
custom_values,
'no_variant_attribute_values'
)
product_id=main_product['product_id'],
add_qty=main_product['quantity'],
product_custom_attribute_values=main_product['product_custom_attribute_values'],
no_variant_attribute_values=main_product['no_variant_attribute_values'],
)
# options have all time the same quantity
for option_id in optional_product_ids:
order._cart_update(
product_id=option_id,
set_qty=value.get('quantity'),
linked_line_id=value.get('line_id'),
product_custom_attribute_values=self._get_product_custom_value(
option_id,
custom_values,
'product_custom_attribute_values'
),
no_variant_attribute_values=self._get_product_custom_value(
option_id,
custom_values,
'no_variant_attribute_values'
# Link option with its parent.
option_parent = {main_product['unique_id']: value['line_id']}
for option in product_and_options[1:]:
parent_unique_id = option['parent_unique_id']
option_value = order._cart_update(
product_id=option['product_id'],
set_qty=option['quantity'],
linked_line_id=option_parent[parent_unique_id],
product_custom_attribute_values=option['product_custom_attribute_values'],
no_variant_attribute_values=option['no_variant_attribute_values'],
)
)
option_parent[option['unique_id']] = option_value['line_id']
return str(order.cart_quantity)
def _get_product_custom_value(self, product_id, custom_values, field):
if custom_values:
for custom_value in custom_values:
if custom_value['product_id'] == product_id:
return custom_value[field]
return None
@@ -1,6 +1,7 @@
odoo.define('website_sale_options.website_sale', function (require) {
'use strict';
var ajax = require('web.ajax');
var core = require('web.core');
var publicWidget = require('web.public.widget');
var OptionalProductsModal = require('sale_product_configurator.OptionalProductsModal');
@@ -67,26 +68,21 @@ publicWidget.registry.WebsiteSale.include({
* @private
* @param {Boolean} goToShop Triggers a page refresh to the url "shop/cart"
*/
_onModalSubmit: function (goToShop){
var customValues = JSON.stringify(
_onModalSubmit: function (goToShop) {
var productAndOptions = JSON.stringify(
this.optionalProductsModal.getSelectedProducts()
);
this.$form.ajaxSubmit({
url: '/shop/cart/update_option',
data: {
lang: this._getContext().lang,
custom_values: customValues
},
success: function (quantity) {
if (goToShop) {
var path = "/shop/cart";
window.location.pathname = path;
}
var $quantity = $(".my_cart_quantity");
$quantity.parent().parent().removeClass("d-none", !quantity);
$quantity.html(quantity).hide().fadeIn(600);
ajax.post('/shop/cart/update_option', {
product_and_options: productAndOptions
}).then(function (quantity) {
if (goToShop) {
var path = "/shop/cart";
window.location.pathname = path;
}
var $quantity = $(".my_cart_quantity");
$quantity.parent().parent().removeClass("d-none", !quantity);
$quantity.html(quantity).hide().fadeIn(600);
});
},
});
@@ -4,10 +4,6 @@
}
}
table.js_cart_lines .optional_product .js_delete_product {
display: none;
}
div#modal_optional_products table tr td {
border: 0;
}