[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:
@@ -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;
|
||||
|
||||
+37
-9
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user