[IMP] sale*: merge configuring products and opt. products modals

The changes concern the following modules:
- sale
- sale_product_configurator
- website_sale
- website_sale_product_configurator

When optional products are enabled and a user adds an item to the cart, the
modal popup does not allow further modifications to the main product based
on its template. If the user wants to change the color or the material, they
have to close the popup, modify the product and open it back up.

The same is applied when Add to Cart is active, and the user cannot configure
the product at all after clicking the add to cart button.

This change will make it possible to change the product variants without having
to close the modal, and configure the product when using the "Add to cart"
button when it's enabled.

The added configuration step is needed for some cases like the following:

1. When adding a product that had variants directly from the /shop page before,
the first variant was added by default and configuring it was impossible without
explicitly going on the product's page and configuring it from there. This 
behaviour made no sense, and it's the reason the "optional products modal"
(which should no longer be called that) is opened when the product is not 
configured even if there are no optional products.
2. When going through the product's page, and there are optional products enabled
for the product getting configured, then it's nice but not necessary to be able to 
configure the product further while choosing and configuring the optional products.
This doesn't add an extra step in any case, it just makes it possible to still configure
your product in the modal.
3. If there are no optional products, the configuration made on the product's
page is taken into account and the product is directly added to the cart without 
opening the modal, which is the same behaviour as previously.

Only one modal is shown, depending on the situation:

- For the website flow, if the product is not configured and has variants or has
optional products (or both), a popup shows that allows for configuration + 
choosing and configuring optional products.
- For the sales flow, there was already a configuration popup, so the optional
products modal is only shown when there are optional products and replaces
the first instead.

Some tests were adapted to function with the new product configuration modal:

- The steps checking for the text mentioned above are removed
- Triggers were adapted to the modal for products that were previously added to cart
  with their default variant
- Various other small adjustments

task-2541663

closes odoo/odoo#73086

Signed-off-by: Romain Derie <rdeodoo@users.noreply.github.com>
This commit is contained in:
Kamen Zhekov
2021-08-30 13:27:44 +00:00
parent e531e20fa8
commit 2b832269f4
23 changed files with 241 additions and 194 deletions
@@ -131,6 +131,7 @@ SectionAndNoteFieldOne2Many.include({
}).open();
MatrixDialog.opened(function () {
MatrixDialog.$content.closest('.o_dialog_container').removeClass('d-none');
if (editedCellAttributes.length > 0) {
var str = editedCellAttributes.toString();
MatrixDialog.$content.find('.o_matrix_input').filter((k, v) => v.attributes.ptav_ids.nodeValue === str)[0].focus();
+1
View File
@@ -23,6 +23,7 @@ class VariantController(http.Controller):
combination = product.product_template_attribute_value_ids
res.update({
'is_combination_possible': product_template._is_combination_possible(combination=combination, parent_combination=parent_combination),
'parent_exclusions': product_template._get_parent_attribute_exclusions(parent_combination=parent_combination)
})
return res
+51 -22
View File
@@ -49,29 +49,50 @@ var VariantMixin = {
* @returns {Deferred}
*/
_getCombinationInfo: function (ev) {
var self = this;
if ($(ev.target).hasClass('variant_custom_value')) {
return Promise.resolve();
}
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());
const $parent = $(ev.target).closest('.js_product');
const combination = this.getSelectedVariantValues($parent);
let parentCombination;
self._checkExclusions($parent, combination);
if ($parent.hasClass('main_product')) {
parentCombination = $parent.find('ul[data-attribute_exclusions]').data('attribute_exclusions').parent_combination;
const $optProducts = $parent.parent().find(`[data-parent-unique-id='${$parent.data('uniqueId')}']`);
for (const optionalProduct of $optProducts) {
const $currentOptionalProduct = $(optionalProduct);
const childCombination = this.getSelectedVariantValues($currentOptionalProduct);
const productTemplateId = parseInt($currentOptionalProduct.find('.product_template_id').val());
ajax.jsonRpc(this._getUri('/sale/get_combination_info'), 'call', {
'product_template_id': productTemplateId,
'product_id': this._getProductId($currentOptionalProduct),
'combination': childCombination,
'add_qty': parseInt($currentOptionalProduct.find('input[name="add_qty"]').val()),
'pricelist_id': this.pricelistId || false,
'parent_combination': combination,
}).then((combinationData) => {
this._onChangeCombination(ev, $currentOptionalProduct, combinationData);
this._checkExclusions($currentOptionalProduct, childCombination, combinationData.parent_exclusions);
});
}
} else {
parentCombination = this.getSelectedVariantValues(
$parent.parent().find('.js_product.in_cart.main_product')
);
}
return ajax.jsonRpc(this._getUri('/sale/get_combination_info'), 'call', {
'product_template_id': productTemplateId,
'product_template_id': parseInt($parent.find('.product_template_id').val()),
'product_id': this._getProductId($parent),
'combination': combination,
'add_qty': parseInt(qty),
'add_qty': parseInt($parent.find('input[name="add_qty"]').val()),
'pricelist_id': this.pricelistId || false,
'parent_combination': parentCombination,
}).then(function (combinationData) {
self._onChangeCombination(ev, $parent, combinationData);
}).then((combinationData) => {
this._onChangeCombination(ev, $parent, combinationData);
this._checkExclusions($parent, combination, combinationData.parent_exclusions);
});
},
@@ -105,6 +126,7 @@ var VariantMixin = {
.data('custom_product_template_attribute_value_id') !== parseInt(attributeValueId)) {
$variantContainer.find('.variant_custom_value').remove();
const previousCustomValue = $customInput.attr("previous_custom_value");
var $input = $('<input>', {
type: 'text',
'data-custom_product_template_attribute_value_id': attributeValueId,
@@ -115,6 +137,9 @@ var VariantMixin = {
$input.attr('placeholder', attributeValueName);
$input.addClass('custom_value_radio');
$variantContainer.append($input);
if (previousCustomValue) {
$input.val(previousCustomValue);
}
$input[0].focus();
}
} else {
@@ -153,8 +178,8 @@ var VariantMixin = {
onChangeAddQuantity: function (ev) {
var $parent;
if ($(ev.currentTarget).closest('.oe_optional_products_modal').length > 0){
$parent = $(ev.currentTarget).closest('.oe_optional_products_modal');
if ($(ev.currentTarget).closest('.oe_advanced_configurator_modal').length > 0){
$parent = $(ev.currentTarget).closest('.oe_advanced_configurator_modal');
} else if ($(ev.currentTarget).closest('form').length > 0){
$parent = $(ev.currentTarget).closest('form');
} else {
@@ -246,16 +271,10 @@ var VariantMixin = {
/**
* Will return the list of selected product.template.attribute.value ids
* For the modal, the "main product"'s attribute values are stored in the
* "unchanged_value_ids" data
*
* @param {$.Element} $container the container to look into
*/
getSelectedVariantValues: function ($container) {
var values = [];
var unchangedValues = $container
.find('div.oe_unchanged_value_ids')
.data('unchanged_value_ids') || [];
var variantsValuesSelectors = [
'input.js_variant_change:checked',
@@ -265,7 +284,7 @@ var VariantMixin = {
values.push(+$(el).val());
});
return values.concat(unchangedValues);
return values;
},
/**
@@ -325,13 +344,18 @@ var VariantMixin = {
* @private
* @param {$.Element} $parent the parent container to apply exclusions
* @param {Array} combination the selected combination of product attribute values
* @param {Array} parentExclusions the exclusions induced by the variant selection of the parent product
* For example chair cannot have steel legs if the parent Desk doesn't have steel legs
*/
_checkExclusions: function ($parent, combination) {
_checkExclusions: function ($parent, combination, parentExclusions) {
var self = this;
var combinationData = $parent
.find('ul[data-attribute_exclusions]')
.data('attribute_exclusions');
if (parentExclusions && combinationData.parent_exclusions) {
combinationData.parent_exclusions = parentExclusions;
}
$parent
.find('option, input, label, .o_variant_pills')
.removeClass('css_not_available')
@@ -565,6 +589,11 @@ var VariantMixin = {
*/
_toggleDisable: function ($parent, isCombinationPossible) {
$parent.toggleClass('css_not_available', !isCombinationPossible);
if ($parent.hasClass('in_cart')) {
const primaryButton = $parent.parents('.modal-content').find('.modal-footer .btn-primary');
primaryButton.prop('disabled', !isCombinationPossible);
primaryButton.toggleClass('disabled', !isCombinationPossible);
}
},
/**
* Updates the product image.
@@ -218,16 +218,11 @@ label.css_attribute_color.css_not_available {
}
}
.modal.o_technical_modal .oe_optional_products_modal .btn.js_add_cart_json {
.modal.o_technical_modal .oe_advanced_configurator_modal .btn.js_add_cart_json {
padding: 0.075rem 0.75rem;
}
.js_product {
&.in_cart {
.js_add_cart_variants {
display: none;
}
}
.td-product_name {
word-wrap: break-word;
@@ -28,10 +28,10 @@ class ProductConfiguratorController(http.Controller):
'product_combination': product_combination
})
@http.route(['/sale_product_configurator/show_optional_products'], type='json', auth="user", methods=['POST'])
def show_optional_products(self, product_id, variant_values, pricelist_id, **kw):
@http.route(['/sale_product_configurator/show_advanced_configurator'], type='json', auth="user", methods=['POST'])
def show_advanced_configurator(self, product_id, variant_values, pricelist_id, **kw):
pricelist = self._get_pricelist(pricelist_id)
return self._show_optional_products(product_id, variant_values, pricelist, False, **kw)
return self._show_advanced_configurator(product_id, variant_values, pricelist, False, **kw)
@http.route(['/sale_product_configurator/optional_product_items'], type='json', auth="user", methods=['POST'])
def optional_product_items(self, product_id, pricelist_id, **kw):
@@ -56,14 +56,9 @@ class ProductConfiguratorController(http.Controller):
'add_qty': add_qty,
})
def _show_optional_products(self, product_id, variant_values, pricelist, handle_stock, **kw):
def _show_advanced_configurator(self, product_id, variant_values, pricelist, handle_stock, **kw):
product = request.env['product.product'].browse(int(product_id))
combination = request.env['product.template.attribute.value'].browse(variant_values)
has_optional_products = product.optional_product_ids.filtered(lambda p: p._is_add_to_cart_possible(combination))
if not has_optional_products:
return False
add_qty = int(kw.get('add_qty', 1))
no_variant_attribute_values = combination.filtered(
@@ -80,6 +75,7 @@ class ProductConfiguratorController(http.Controller):
'variant_values': variant_values,
'pricelist': pricelist,
'handle_stock': handle_stock,
'already_configured': kw.get("already_configured", False)
})
def _get_pricelist(self, pricelist_id, pricelist_fallback=False):
@@ -52,6 +52,9 @@ var ProductConfiguratorFormController = FormController.extend({
if (this.renderer.state.context.configuratorMode === 'options') {
this.$el.closest('.modal').addClass('d-none');
}
const renderSecondOnly = this.renderer.state.context.configuratorMode !== 'edit';
this.$el.closest('.o_dialog_container').toggleClass('d-none', renderSecondOnly);
},
//--------------------------------------------------------------------------
@@ -237,7 +240,9 @@ var ProductConfiguratorFormController = FormController.extend({
*/
_onModalConfirm: function () {
this._wasConfirmed = true;
this._addProducts(this.optionalProductsModal.getSelectedProducts());
this.optionalProductsModal.getAndCreateSelectedProducts().then((products) => {
this._addProducts(products);
});
},
/**
@@ -247,9 +252,9 @@ var ProductConfiguratorFormController = FormController.extend({
* @private
*/
_onModalClose: function () {
if (this.renderer.state.context.configuratorMode === 'options'
if (['options', 'add'].includes(this.renderer.state.context.configuratorMode)
&& this._wasConfirmed !== true) {
this.do_action({type: 'ir.actions.act_window_close'});
this.do_action({type: 'ir.actions.act_window_close'});
}
},
@@ -58,7 +58,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
this.container = parent;
this.pricelistId = params.pricelistId;
this.previousModalHeight = params.previousModalHeight;
this.dialogClass = 'oe_optional_products_modal';
this.dialogClass = 'oe_advanced_configurator_modal';
this._productImageField = 'image_128';
this._opened.then(function () {
@@ -73,7 +73,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
willStart: function () {
var self = this;
var uri = this._getUri("/sale_product_configurator/show_optional_products");
var uri = this._getUri("/sale_product_configurator/show_advanced_configurator");
var getModalContent = ajax.jsonRpc(uri, 'call', {
product_id: self.rootProduct.product_id,
variant_values: self.rootProduct.variant_values,
@@ -178,18 +178,27 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
* {Array} no_variant_attribute_values
* @public
*/
getSelectedProducts: function () {
getAndCreateSelectedProducts: async function () {
var self = this;
var products = [this.rootProduct];
this.$modal.find('.js_product.in_cart:not(.main_product)').each(function () {
var $item = $(this);
const products = [];
let productCustomVariantValues;
let noVariantAttributeValues;
for (const product of self.$modal.find('.js_product.in_cart')) {
var $item = $(product);
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));
var parentUniqueId = product.dataset.parentUniqueId;
var uniqueId = product.dataset.uniqueId;
productCustomVariantValues = self.getCustomVariantValues($item);
noVariantAttributeValues = self.getNoVariantAttributeValues($item);
const productID = await self.selectOrCreateProduct(
$item,
parseInt($item.find('input.product_id').val(), 10),
parseInt($item.find('input.product_template_id').val(), 10),
true
);
products.push({
'product_id': parseInt($item.find('input.product_id').val(), 10),
'product_id': productID,
'product_template_id': parseInt($item.find('input.product_template_id').val(), 10),
'quantity': quantity,
'parent_unique_id': parentUniqueId,
@@ -197,8 +206,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
'product_custom_attribute_values': productCustomVariantValues,
'no_variant_attribute_values': noVariantAttributeValues
});
});
}
return products;
},
@@ -229,14 +237,18 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
text: $productDescription.text()
}));
$.each(this.rootProduct.product_custom_attribute_values, function (){
$updatedDescription.append($('<div>', {
text: this.attribute_value_name + ': ' + this.custom_value
}));
$.each(this.rootProduct.product_custom_attribute_values, function () {
if (this.custom_value) {
const $customInput = $modalContent
.find(".main_product [data-is_custom='True']")
.closest(`[data-value_id='${this.custom_product_template_attribute_value_id}']`);
$customInput.attr('previous_custom_value', this.custom_value);
VariantMixin.handleCustomValues($customInput);
}
});
$.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') {
$updatedDescription.append($('<div>', {
text: this.attribute_name + ': ' + this.attribute_value_name
}));
@@ -282,7 +294,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
ev.preventDefault();
var self = this;
var $target = $(ev.currentTarget);
var $modal = $target.parents('.oe_optional_products_modal');
var $modal = $target.parents('.oe_advanced_configurator_modal');
var $parent = $target.parents('.js_product:first');
$parent.find("a.js_add, span.js_remove").toggleClass('d-none');
$parent.find(".js_remove");
@@ -54,6 +54,7 @@ var ProductConfiguratorFormRenderer = FormRenderer.extend(VariantMixin, {
this.triggerVariantChange($configuratorContainer);
this._applyCustomValues();
this.trigger_up('handle_add');
},
//--------------------------------------------------------------------------
@@ -41,89 +41,62 @@ tour.register('sale_product_configurator_advanced_tour', {
run: 'click'
}, {
trigger: 'span:contains("Custom")',
extra_trigger: '.o_product_configurator',
extra_trigger: '.oe_advanced_configurator_modal',
run: 'click'
}, {
trigger: '.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(1) .variant_custom_value',
extra_trigger: '.o_product_configurator',
trigger: '.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(1) .variant_custom_value',
extra_trigger: '.oe_advanced_configurator_modal',
run: 'text Custom 1'
}, {
trigger: '.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(3) span:contains("PAV9")',
extra_trigger: '.o_product_configurator',
trigger: '.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(3) span:contains("PAV9")',
extra_trigger: '.oe_advanced_configurator_modal',
run: 'click'
}, {
trigger: '.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(3) .variant_custom_value',
extra_trigger: '.o_product_configurator',
trigger: '.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(3) .variant_custom_value',
extra_trigger: '.oe_advanced_configurator_modal',
run: 'text Custom 2'
}, {
trigger: '.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(4) span:contains("PAV5")',
extra_trigger: '.o_product_configurator',
trigger: '.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(4) span:contains("PAV5")',
extra_trigger: '.oe_advanced_configurator_modal',
run: 'click'
}, {
trigger: '.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) select ',
extra_trigger: '.o_product_configurator',
trigger: '.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) select ',
extra_trigger: '.oe_advanced_configurator_modal',
run: function (){
var inputValue = $('.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) option[data-value_name="PAV9"]').val();
$('.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) select').val(inputValue);
$('.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) select').trigger('change');
var inputValue = $('.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) option[data-value_name="PAV9"]').val();
$('.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) select').val(inputValue);
$('.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) select').trigger('change');
}
}, {
trigger: '.o_product_configurator ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) .variant_custom_value',
extra_trigger: '.o_product_configurator',
trigger: '.oe_advanced_configurator_modal ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) .variant_custom_value',
extra_trigger: '.oe_advanced_configurator_modal',
run: 'text Custom 3'
}, {
trigger: ".o_sale_product_configurator_add",
run: 'click'
}, {
trigger: '.main_product strong:contains("Custom, White, PAV9, PAV5, PAV1")',
extra_trigger: '.oe_optional_products_modal',
extra_trigger: '.oe_advanced_configurator_modal',
run: function () {} //check
}, {
trigger: '.main_product div:contains("Custom: Custom 1")',
extra_trigger: '.oe_optional_products_modal',
run: function () {} //check
}, {
trigger: '.main_product div:contains("PAV9: Custom 2")',
extra_trigger: '.oe_optional_products_modal',
run: function () {} //check
}, {
trigger: '.main_product div:contains("PAV9: Custom 3")',
extra_trigger: '.oe_optional_products_modal',
run: function () {} //check
}, {
trigger: '.main_product div:contains("PA5: PAV1")',
extra_trigger: '.oe_optional_products_modal',
run: function () {} //check
}, {
trigger: '.main_product div:contains("PA7: PAV1")',
extra_trigger: '.oe_optional_products_modal',
run: function () {} //check
}, {
trigger: '.main_product div:contains("PA8: PAV1")',
extra_trigger: '.oe_optional_products_modal',
run: function () {} //check
}, {
trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (TEST) (Steel)")',
trigger: '.oe_advanced_configurator_modal .js_product:eq(1) div:contains("Conference Chair (TEST) (Steel)")',
run: function () {
optionVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
optionVariantImage = $('.oe_advanced_configurator_modal .js_product:eq(1) img.variant_image').attr('src');
}
}, {
trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Aluminium"]',
trigger: '.oe_advanced_configurator_modal .js_product:eq(1) input[data-value_name="Aluminium"]',
}, {
trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (TEST) (Aluminium)")',
trigger: '.oe_advanced_configurator_modal .js_product:eq(1) div:contains("Conference Chair (TEST) (Aluminium)")',
run: function () {
var newVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
var newVariantImage = $('.oe_advanced_configurator_modal .js_product:eq(1) img.variant_image').attr('src');
if (newVariantImage !== optionVariantImage) {
$('<p>').text('image variant option src changed').insertAfter('.oe_optional_products_modal .js_product:eq(1) .product-name');
$('<p>').text('image variant option src changed').insertAfter('.oe_advanced_configurator_modal .js_product:eq(1) .product-name');
}
}
}, {
extra_trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("image variant option src changed")',
trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Steel"]',
extra_trigger: '.oe_advanced_configurator_modal .js_product:eq(1) div:contains("image variant option src changed")',
trigger: '.oe_advanced_configurator_modal .js_product:eq(1) input[data-value_name="Steel"]',
}, {
trigger: 'button span:contains(Confirm)',
extra_trigger: '.oe_optional_products_modal',
extra_trigger: '.oe_advanced_configurator_modal',
run: 'click'
}, {
trigger: 'td.o_data_cell:contains("Customizable Desk (TEST) (Custom, White, PAV9, PAV5, PAV1)")',
@@ -31,22 +31,24 @@ tour.register('sale_product_configurator_edition_tour', {
trigger: 'ul.ui-autocomplete a:contains("Customizable Desk (TEST)")',
run: 'click'
}, {
trigger: '.configurator_container span:contains("Steel")',
trigger: '.main_product span:contains("Steel")',
run: function () {
$('input.product_id').change(function () {
$('.o_sale_product_configurator_add').attr('request_count', 1);
$('.show .modal-footer .btn-primary').attr('request_count', 1);
});
}
}, {
trigger: '.configurator_container span:contains("Aluminium")'
trigger: '.main_product span:contains("Aluminium")'
}, {
trigger: '.o_sale_product_configurator_add[request_count="1"]',
trigger: '.btn-primary[request_count="1"]',
extra_trigger: '.show .modal-footer',
run: function (){} // used to sync with "get_combination_info" completion
}, {
trigger: '.o_sale_product_configurator_add:not(.disabled)'
trigger: '.btn-primary:not(.disabled)',
extra_trigger: '.show .modal-footer',
}, {
trigger: 'button span:contains(Confirm)',
extra_trigger: '.oe_optional_products_modal',
extra_trigger: '.oe_advanced_configurator_modal',
run: 'click'
}, {
trigger: 'td.o_data_cell:contains("Customizable Desk (TEST) (Aluminium, White)")',
@@ -30,8 +30,6 @@ tour.register('sale_product_configurator_optional_products_tour', {
}, {
trigger: 'ul.ui-autocomplete a:contains("Customizable Desk (TEST)")',
run: 'click'
}, {
trigger: '.o_sale_product_configurator_add'
}, {
trigger: 'tr:has(.td-product_name:contains("Office Chair Black")) .js_add',
}, {
@@ -52,7 +50,7 @@ tour.register('sale_product_configurator_optional_products_tour', {
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',
extra_trigger: '.oe_advanced_configurator_modal',
run: 'click'
}, {
trigger: 'tr:has(td.o_data_cell:contains("Customizable Desk")) td.o_data_cell:contains("2.0")',
@@ -61,23 +61,20 @@ tour.stepUtils.showAppsMenuItem(),
content: "check price for 2",
trigger: 'span.oe_currency_value:contains("600.00")',
run: function () {} // check price (pricelist has discount for 2)
}, {
content: "click add",
trigger: '.o_sale_product_configurator_add:not(.disabled)'
}, {
content: "check we are on the add modal",
trigger: '.td-product_name:contains("Customizable Desk (TEST) (Steel, White)")',
extra_trigger: '.oe_optional_products_modal',
extra_trigger: '.oe_advanced_configurator_modal',
run: 'click'
}, {
content: "add conference chair",
trigger: '.js_product:has(strong:contains(Conference Chair)) .js_add',
extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Conference Chair))',
extra_trigger: '.oe_advanced_configurator_modal .js_product:has(strong:contains(Conference Chair))',
run: 'click'
}, {
content: "add chair floor protection",
trigger: '.js_product:has(strong:contains(Chair floor protection)) .js_add',
extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Chair floor protection))',
extra_trigger: '.oe_advanced_configurator_modal .js_product:has(strong:contains(Chair floor protection))',
run: 'click'
}, {
content: "verify configurator final price", // tax excluded
@@ -85,7 +82,7 @@ tour.stepUtils.showAppsMenuItem(),
}, {
content: "add to SO",
trigger: 'button span:contains(Confirm)',
extra_trigger: '.oe_optional_products_modal',
extra_trigger: '.oe_advanced_configurator_modal',
run: 'click'
}, {
content: "verify SO final price excluded",
@@ -32,26 +32,24 @@ tour.register('sale_product_configurator_single_custom_attribute_tour', {
trigger: 'ul.ui-autocomplete a:contains("Customizable Desk (TEST)")',
run: 'click'
}, {
trigger: '.configurator_container span:contains("Aluminium")',
trigger: '.oe_advanced_configurator_modal span:contains("Aluminium")',
run: function () {
// used to check that the radio is NOT rendered
if ($('.configurator_container ul[data-attribute_id].d-none input[data-value_name="single product attribute value"]').length === 1) {
$('.configurator_container').addClass('tour_success');
if ($('.oe_advanced_configurator_modal ul[data-attribute_id].d-none input[data-value_name="single product attribute value"]').length === 1) {
$('.oe_advanced_configurator_modal').addClass('tour_success');
}
}
}, {
trigger: '.configurator_container.tour_success',
trigger: '.oe_advanced_configurator_modal.tour_success',
run: function () {
//check
}
}, {
trigger: '.configurator_container .variant_custom_value',
trigger: '.oe_advanced_configurator_modal .variant_custom_value',
run: 'text great single custom value'
}, {
trigger: '.o_sale_product_configurator_add',
}, {
trigger: 'button span:contains(Confirm)',
extra_trigger: '.oe_optional_products_modal',
extra_trigger: '.oe_advanced_configurator_modal',
run: 'click'
}, {
trigger: 'td.o_data_cell:contains("single product attribute value: great single custom value")',
@@ -35,10 +35,10 @@ tour.register('sale_product_configurator_tour', {
trigger: 'ul.ui-autocomplete a:contains("Customizable Desk (TEST)")',
run: 'click'
}, {
trigger: '.configurator_container span:contains("Steel")',
trigger: '.main_product span:contains("Steel")',
run: function () {},
}, {
trigger: '.configurator_container span:contains("Aluminium")',
trigger: '.main_product span:contains("Aluminium")',
run: 'click'
}, {
trigger: 'span.oe_currency_value:contains("800.40")',
@@ -46,26 +46,24 @@ tour.register('sale_product_configurator_tour', {
}, {
trigger: 'input[data-value_name="Black"]'
}, {
trigger: '.o_sale_product_configurator_add.disabled'
trigger: '.btn-primary.disabled',
extra_trigger: '.show .modal-footer'
}, {
trigger: 'input[data-value_name="White"]'
}, {
trigger: '.o_sale_product_configurator_add:not(.disabled)'
trigger: '.btn-primary:not(.disabled)',
extra_trigger: '.show .modal-footer'
}, {
trigger: 'span:contains("Aluminium")',
extra_trigger: '.oe_optional_products_modal',
run: 'click'
}, {
trigger: '.js_product:has(strong:contains(Conference Chair)) .js_add',
extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Conference Chair))',
trigger: 'span:contains("Aluminium"):eq(1)',
extra_trigger: '.oe_advanced_configurator_modal',
run: 'click'
}, {
trigger: '.js_product:has(strong:contains(Chair floor protection)) .js_add',
extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Chair floor protection))',
extra_trigger: '.oe_advanced_configurator_modal',
run: 'click'
}, {
trigger: 'button span:contains(Confirm)',
extra_trigger: '.oe_optional_products_modal',
extra_trigger: '.oe_advanced_configurator_modal',
id: "quotation_product_selected",
run: 'click'
},
@@ -5,7 +5,7 @@ import odoo.tests
from .common import TestProductConfiguratorCommon
@odoo.tests.tagged('post_install', '-at_install')
@odoo.tests.tagged('post_install', '-at_install', 'kzh')
class TestUi(odoo.tests.HttpCase, TestProductConfiguratorCommon):
def setUp(self):
@@ -32,6 +32,8 @@
<input type="hidden" class="product_template_id" t-att-value="product.id"/>
<input type="hidden" class="product_id" t-attf-name="product_id" t-att-value="product_variant.id"/>
<input type="hidden" class="has_optional_products" t-attf-name="has_optional_products"
t-att-value="product_variant.optional_product_ids.filtered(lambda p: p._is_add_to_cart_possible(combination))"/>
<div class="col-lg-12 text-center mt-5">
<t t-if="product._is_add_to_cart_possible()">
<div class="col-lg-5 d-inline-block text-left">
@@ -93,22 +95,32 @@
<input type="hidden" class="product_template_id" t-att-value="product.product_tmpl_id.id"/>
<input type="hidden" class="product_id" t-att-value="product_variant.id"/>
<td class='td-img'>
<img t-if="product_variant" t-att-src="'/web/image/product.product/%s/image_128' % product_variant.id" alt="Product Image"/>
<img t-else="" t-att-src="'/web/image/product.template/%s/image_128' % product.id" alt="Product Image"/>
<img class="product_detail_img" t-if="product_variant" t-att-src="'/web/image/product.product/%s/image_128' % product_variant.id" alt="Product Image"/>
<img class="product_detail_img" t-else="" t-att-src="'/web/image/product.template/%s/image_128' % product.id" alt="Product Image"/>
</td>
<td class='td-product_name'>
<strong t-esc="combination_info['display_name']"/>
<strong class="product-name product_display_name" t-esc="combination_info['display_name']"/>
<div class="text-muted small">
<div t-field="product.description_sale"/>
<div class="js_attributes"/>
</div>
<div>
<t t-if="product.product_tmpl_id and not combination">
<t t-set="combination" t-value="product.product_tmpl_id._get_first_possible_combination()"/>
</t>
<t t-if="combination and not already_configured" t-call="sale.variants">
<t t-set="ul_class" t-valuef="flex-column" />
<t t-set="product" t-value="product.product_tmpl_id"/>
</t>
<t t-else="">
<ul class="d-none js_add_cart_variants" t-att-data-attribute_exclusions="{'exclusions: []'}"/>
</t>
</div>
</td>
<td class="text-center td-qty">
<t t-call='sale_product_configurator.product_quantity_config' />
</td>
<td class="text-center td-price" name="price">
<ul class="d-none js_add_cart_variants" t-att-data-attribute_exclusions="{'exclusions: []'}"></ul>
<div class="d-none oe_unchanged_value_ids" t-att-data-unchanged_value_ids="variant_values" ></div>
<div t-attf-class="text-danger oe_default_price oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
t-esc="combination_info['list_price']"
t-options='{
@@ -124,6 +136,7 @@
"display_currency": (pricelist or product).currency_id
}'/>
<span class="js_raw_price d-none" t-esc="product.price"/>
<p class="css_not_available_msg alert alert-warning">Option not available</p>
</td>
</tr>
<tr class="o_total_row">
@@ -167,7 +180,9 @@
<strong class="product-name product_display_name" t-esc="combination_info['display_name']"/>
<div class="text-muted small" t-field="product.description_sale"/>
</div>
<t t-call="sale.variants"/>
<t t-call="sale.variants">
<t t-set="combination" t-value="product._get_first_possible_combination(parent_combination)"/>
</t>
</td>
<td class="text-center td-qty d-none">
<t t-call='sale_product_configurator.product_quantity_config' />
@@ -18,6 +18,26 @@ VariantMixin._getUri = function (uri) {
}
};
const originalToggleDisable = VariantMixin._toggleDisable;
/**
* Toggles the disabled class depending on the $parent element
* and the possibility of the current combination. This override
* allows us to disable the secondary button in the website
* sale product configuration modal.
*
* @private
* @param {$.Element} $parent
* @param {boolean} isCombinationPossible
*/
VariantMixin._toggleDisable = function ($parent, isCombinationPossible) {
if ($parent.hasClass('in_cart')) {
const secondaryButton = $parent.parents('.modal-content').find('.modal-footer .btn-secondary');
secondaryButton.prop('disabled', !isCombinationPossible);
secondaryButton.toggleClass('disabled', !isCombinationPossible);
}
originalToggleDisable.apply(this, [$parent, isCombinationPossible]);
};
return VariantMixin;
});
@@ -177,7 +177,7 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, car
'click #add_to_cart, .o_we_buy_now, #products_grid .o_wsale_product_btn .a-submit': 'async _onClickAdd',
'click input.js_product_change': 'onChangeVariant',
'change .js_main_product [data-attribute_exclusions]': 'onChangeVariant',
'change oe_optional_products_modal [data-attribute_exclusions]': 'onChangeVariant',
'change oe_advanced_configurator_modal [data-attribute_exclusions]': 'onChangeVariant',
'click .o_product_page_reviews_link': '_onClickReviewsLink',
}),
+1 -1
View File
@@ -600,7 +600,7 @@
<input type="hidden" class="product_template_id" name="product_template_id" t-att-value="product.id" />
<input t-if="product.public_categ_ids.ids" type="hidden" class="product_category_id" name="product_category_id" t-att-value="product.public_categ_ids.ids[0]" />
<t t-if="combination" t-call="sale.variants">
<t t-set="ul_class" t-value="'flex-column'" />
<t t-set="ul_class" t-valuef="flex-column" />
<t t-set="parent_combination" t-value="None" />
</t>
<t t-else="">
@@ -7,14 +7,25 @@ from odoo.http import request
from odoo.addons.sale_product_configurator.controllers.main import ProductConfiguratorController
from odoo.addons.website_sale.controllers import main
class WebsiteSaleProductConfiguratorController(ProductConfiguratorController):
@http.route(['/sale_product_configurator/show_optional_products_website'], type='json', auth="public", methods=['POST'], website=True)
def show_optional_products_website(self, product_id, variant_values, **kw):
@http.route(['/sale_product_configurator/show_advanced_configurator_website'], type='json', auth="public", methods=['POST'], website=True)
def show_advanced_configurator_website(self, product_id, variant_values, **kw):
"""Special route to use website logic in get_combination_info override.
This route is called in JS by appending _website to the base route.
"""
kw.pop('pricelist_id')
return self.show_optional_products(product_id, variant_values, request.website.get_current_pricelist(), **kw)
product = request.env['product.product'].browse(int(product_id))
combination = request.env['product.template.attribute.value'].browse(variant_values)
has_optional_products = product.optional_product_ids.filtered(lambda p: p._is_add_to_cart_possible(combination))
if not has_optional_products and (product.product_variant_count <= 1 or variant_values):
# The modal is not shown if there are no optional products and
# the main product either has no variants or is already configured
return False
if variant_values:
kw["already_configured"] = True
return self.show_advanced_configurator(product_id, variant_values, request.website.get_current_pricelist(), **kw)
@http.route(['/sale_product_configurator/optional_product_items_website'], type='json', auth="public", methods=['POST'], website=True)
def optional_product_items_website(self, product_id, **kw):
@@ -24,6 +35,7 @@ class WebsiteSaleProductConfiguratorController(ProductConfiguratorController):
kw.pop('pricelist_id')
return self.optional_product_items(product_id, request.website.get_current_pricelist(), **kw)
class WebsiteSale(main.WebsiteSale):
def _prepare_product_values(self, product, category, search, **kwargs):
values = super(WebsiteSale, self)._prepare_product_values(product, category, search, **kwargs)
@@ -24,7 +24,7 @@ OptionalProductsModal.include({
this._super.apply(this, arguments);
this.isWebsite = params.isWebsite;
this.dialogClass = 'oe_optional_products_modal' + (params.isWebsite ? ' oe_website_sale' : '');
this.dialogClass = 'oe_advanced_configurator_modal' + (params.isWebsite ? ' oe_website_sale' : '');
},
/**
* @override
@@ -80,21 +80,19 @@ publicWidget.registry.WebsiteSale.include({
* @param {Boolean} goToShop Triggers a page refresh to the url "shop/cart"
*/
_onModalSubmit: function (goToShop) {
var productAndOptions = JSON.stringify(
this.optionalProductsModal.getSelectedProducts()
);
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.html(quantity).hide().fadeIn(600);
});
this.optionalProductsModal.getAndCreateSelectedProducts()
.then((products) => {
const productAndOptions = JSON.stringify(products);
ajax.post('/shop/cart/update_option', {product_and_options: productAndOptions})
.then(function (quantity) {
if (goToShop) {
window.location.pathname = "/shop/cart";
}
const $quantity = $(".my_cart_quantity");
$quantity.parent().parent().removeClass('d-none');
$quantity.text(quantity).hide().fadeIn(600);
});
});
},
});
@@ -24,40 +24,36 @@ tour.register("a_shop_custom_attribute_value", {
trigger: 'a:contains(ADD TO CART)',
run: 'click',
}, {
trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (TEST) (Steel)")',
trigger: '.oe_advanced_configurator_modal .js_product:eq(1) div:contains("Conference Chair (TEST) (Steel)")',
run: function () {
optionVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
optionVariantImage = $('.oe_advanced_configurator_modal .js_product:eq(1) img.variant_image').attr('src');
}
}, {
trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Aluminium"]',
trigger: '.oe_advanced_configurator_modal .js_product:eq(1) input[data-value_name="Aluminium"]',
}, {
trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (TEST) (Aluminium)")',
trigger: '.oe_advanced_configurator_modal .js_product:eq(1) div:contains("Conference Chair (TEST) (Aluminium)")',
run: function () {
var newVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
var newVariantImage = $('.oe_advanced_configurator_modal .js_product:eq(1) img.variant_image').attr('src');
if (newVariantImage !== optionVariantImage) {
$('<p>').text('image variant option src changed').insertAfter('.oe_optional_products_modal .js_product:eq(1) .product-name');
$('<p>').text('image variant option src changed').insertAfter('.oe_advanced_configurator_modal .js_product:eq(1) .product-name');
}
}
}, {
extra_trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("image variant option src changed")',
trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Steel"]',
}, {
trigger: 'li.js_attribute_value span:contains(Aluminium)',
extra_trigger: '.oe_optional_products_modal',
run: 'click'
extra_trigger: '.oe_advanced_configurator_modal .js_product:eq(1) div:contains("image variant option src changed")',
trigger: '.oe_advanced_configurator_modal .js_product:eq(1) input[data-value_name="Steel"]',
}, {
trigger: '.oe_price span:contains(22.90)',
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))',
trigger: '.oe_advanced_configurator_modal .js_product:has(strong:contains(Conference Chair)) .js_add',
extra_trigger: '.oe_advanced_configurator_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))',
trigger: '.oe_advanced_configurator_modal .js_product:has(strong:contains(Chair floor protection)) .js_add',
extra_trigger: '.oe_advanced_configurator_modal .js_product:has(strong:contains(Chair floor protection))',
run: 'click'
}, {
trigger: 'span:contains(1,269.80)',
trigger: 'span:contains(1,257.00)',
run: function (){}, // check
}, {
trigger: 'button:has(span:contains(Proceed to Checkout))',