From 2b832269f474d9b08c7c6cd827e5f78ebcfafeca Mon Sep 17 00:00:00 2001 From: Kamen Zhekov Date: Thu, 1 Jul 2021 14:41:32 +0000 Subject: [PATCH] [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 --- .../static/src/js/section_and_note_widget.js | 1 + addons/sale/controllers/variant.py | 1 + addons/sale/static/src/js/variant_mixin.js | 73 +++++++++++------ .../static/src/scss/product_configurator.scss | 7 +- .../controllers/main.py | 14 ++-- .../src/js/product_configurator_controller.js | 11 ++- .../src/js/product_configurator_modal.js | 52 +++++++----- .../src/js/product_configurator_renderer.js | 1 + .../tours/product_configurator_advanced_ui.js | 79 ++++++------------- .../tours/product_configurator_edition_ui.js | 14 ++-- ...oduct_configurator_optional_products_ui.js | 4 +- .../product_configurator_pricelist_ui.js | 11 +-- ...configurator_single_custom_attribute_ui.js | 14 ++-- .../tests/tours/product_configurator_ui.js | 22 +++--- .../test_sale_product_configurator_ui.py | 2 +- .../views/templates.xml | 27 +++++-- .../static/src/js/variant_mixin.js | 20 +++++ .../static/src/js/website_sale.js | 2 +- addons/website_sale/views/templates.xml | 2 +- .../controllers/main.py | 18 ++++- .../src/js/product_configurator_modal.js | 2 +- .../static/src/js/website_sale_options.js | 28 +++---- ...bsite_sale_shop_custom_attributes_value.js | 30 +++---- 23 files changed, 241 insertions(+), 194 deletions(-) diff --git a/addons/product_matrix/static/src/js/section_and_note_widget.js b/addons/product_matrix/static/src/js/section_and_note_widget.js index c59efc06e50..5d9499e52e0 100644 --- a/addons/product_matrix/static/src/js/section_and_note_widget.js +++ b/addons/product_matrix/static/src/js/section_and_note_widget.js @@ -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(); diff --git a/addons/sale/controllers/variant.py b/addons/sale/controllers/variant.py index 3afdc19bd8d..694d8561040 100644 --- a/addons/sale/controllers/variant.py +++ b/addons/sale/controllers/variant.py @@ -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 diff --git a/addons/sale/static/src/js/variant_mixin.js b/addons/sale/static/src/js/variant_mixin.js index 4447be0ada0..ab7aea9a0a7 100644 --- a/addons/sale/static/src/js/variant_mixin.js +++ b/addons/sale/static/src/js/variant_mixin.js @@ -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 = $('', { 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. diff --git a/addons/sale/static/src/scss/product_configurator.scss b/addons/sale/static/src/scss/product_configurator.scss index f3f2669bd97..84a4fa8ad47 100644 --- a/addons/sale/static/src/scss/product_configurator.scss +++ b/addons/sale/static/src/scss/product_configurator.scss @@ -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; diff --git a/addons/sale_product_configurator/controllers/main.py b/addons/sale_product_configurator/controllers/main.py index d207d41694e..541246ba703 100644 --- a/addons/sale_product_configurator/controllers/main.py +++ b/addons/sale_product_configurator/controllers/main.py @@ -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): diff --git a/addons/sale_product_configurator/static/src/js/product_configurator_controller.js b/addons/sale_product_configurator/static/src/js/product_configurator_controller.js index 008e6e65fea..6346adeba18 100644 --- a/addons/sale_product_configurator/static/src/js/product_configurator_controller.js +++ b/addons/sale_product_configurator/static/src/js/product_configurator_controller.js @@ -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'}); } }, diff --git a/addons/sale_product_configurator/static/src/js/product_configurator_modal.js b/addons/sale_product_configurator/static/src/js/product_configurator_modal.js index b495100abe7..424b52418d6 100644 --- a/addons/sale_product_configurator/static/src/js/product_configurator_modal.js +++ b/addons/sale_product_configurator/static/src/js/product_configurator_modal.js @@ -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($('
', { - 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($('
', { 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"); diff --git a/addons/sale_product_configurator/static/src/js/product_configurator_renderer.js b/addons/sale_product_configurator/static/src/js/product_configurator_renderer.js index f0009e42c38..90ef91823b4 100644 --- a/addons/sale_product_configurator/static/src/js/product_configurator_renderer.js +++ b/addons/sale_product_configurator/static/src/js/product_configurator_renderer.js @@ -54,6 +54,7 @@ var ProductConfiguratorFormRenderer = FormRenderer.extend(VariantMixin, { this.triggerVariantChange($configuratorContainer); this._applyCustomValues(); + this.trigger_up('handle_add'); }, //-------------------------------------------------------------------------- diff --git a/addons/sale_product_configurator/static/tests/tours/product_configurator_advanced_ui.js b/addons/sale_product_configurator/static/tests/tours/product_configurator_advanced_ui.js index fb7db5dd951..e186e592ac3 100644 --- a/addons/sale_product_configurator/static/tests/tours/product_configurator_advanced_ui.js +++ b/addons/sale_product_configurator/static/tests/tours/product_configurator_advanced_ui.js @@ -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) { - $('

').text('image variant option src changed').insertAfter('.oe_optional_products_modal .js_product:eq(1) .product-name'); + $('

').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)")', diff --git a/addons/sale_product_configurator/static/tests/tours/product_configurator_edition_ui.js b/addons/sale_product_configurator/static/tests/tours/product_configurator_edition_ui.js index dc7663d6417..8f908ef9fcb 100644 --- a/addons/sale_product_configurator/static/tests/tours/product_configurator_edition_ui.js +++ b/addons/sale_product_configurator/static/tests/tours/product_configurator_edition_ui.js @@ -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)")', diff --git a/addons/sale_product_configurator/static/tests/tours/product_configurator_optional_products_ui.js b/addons/sale_product_configurator/static/tests/tours/product_configurator_optional_products_ui.js index c566ce5b1da..e1ae2f75948 100644 --- a/addons/sale_product_configurator/static/tests/tours/product_configurator_optional_products_ui.js +++ b/addons/sale_product_configurator/static/tests/tours/product_configurator_optional_products_ui.js @@ -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")', diff --git a/addons/sale_product_configurator/static/tests/tours/product_configurator_pricelist_ui.js b/addons/sale_product_configurator/static/tests/tours/product_configurator_pricelist_ui.js index af97bf1f7c7..532e806a536 100644 --- a/addons/sale_product_configurator/static/tests/tours/product_configurator_pricelist_ui.js +++ b/addons/sale_product_configurator/static/tests/tours/product_configurator_pricelist_ui.js @@ -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", diff --git a/addons/sale_product_configurator/static/tests/tours/product_configurator_single_custom_attribute_ui.js b/addons/sale_product_configurator/static/tests/tours/product_configurator_single_custom_attribute_ui.js index 8b1db607579..aab9074d415 100644 --- a/addons/sale_product_configurator/static/tests/tours/product_configurator_single_custom_attribute_ui.js +++ b/addons/sale_product_configurator/static/tests/tours/product_configurator_single_custom_attribute_ui.js @@ -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")', diff --git a/addons/sale_product_configurator/static/tests/tours/product_configurator_ui.js b/addons/sale_product_configurator/static/tests/tours/product_configurator_ui.js index fad1fbd3309..4eb22b94063 100644 --- a/addons/sale_product_configurator/static/tests/tours/product_configurator_ui.js +++ b/addons/sale_product_configurator/static/tests/tours/product_configurator_ui.js @@ -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' }, diff --git a/addons/sale_product_configurator/tests/test_sale_product_configurator_ui.py b/addons/sale_product_configurator/tests/test_sale_product_configurator_ui.py index 427a7dcdb9f..95ab4df737a 100644 --- a/addons/sale_product_configurator/tests/test_sale_product_configurator_ui.py +++ b/addons/sale_product_configurator/tests/test_sale_product_configurator_ui.py @@ -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): diff --git a/addons/sale_product_configurator/views/templates.xml b/addons/sale_product_configurator/views/templates.xml index 215d6d49590..a5e04bbb18d 100644 --- a/addons/sale_product_configurator/views/templates.xml +++ b/addons/sale_product_configurator/views/templates.xml @@ -32,6 +32,8 @@ +

@@ -93,22 +95,32 @@ - Product Image - Product Image + Product Image + Product Image - +
+
+ + + + + + + + +
    + +
-
    -
    +

    Option not available

    @@ -167,7 +180,9 @@
    - + + + diff --git a/addons/website_sale/static/src/js/variant_mixin.js b/addons/website_sale/static/src/js/variant_mixin.js index 0e92564bd5c..a2afda68f5e 100644 --- a/addons/website_sale/static/src/js/variant_mixin.js +++ b/addons/website_sale/static/src/js/variant_mixin.js @@ -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; }); diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js index af6d843167a..7ae80105d97 100644 --- a/addons/website_sale/static/src/js/website_sale.js +++ b/addons/website_sale/static/src/js/website_sale.js @@ -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', }), diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 9f8282adb5a..b73c87fdedf 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -600,7 +600,7 @@ - + diff --git a/addons/website_sale_product_configurator/controllers/main.py b/addons/website_sale_product_configurator/controllers/main.py index ae6ddc5edee..cca3ae2d37a 100644 --- a/addons/website_sale_product_configurator/controllers/main.py +++ b/addons/website_sale_product_configurator/controllers/main.py @@ -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) diff --git a/addons/website_sale_product_configurator/static/src/js/product_configurator_modal.js b/addons/website_sale_product_configurator/static/src/js/product_configurator_modal.js index 940d7e04a63..5b00d2d26ca 100644 --- a/addons/website_sale_product_configurator/static/src/js/product_configurator_modal.js +++ b/addons/website_sale_product_configurator/static/src/js/product_configurator_modal.js @@ -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 diff --git a/addons/website_sale_product_configurator/static/src/js/website_sale_options.js b/addons/website_sale_product_configurator/static/src/js/website_sale_options.js index 623fa10233a..7524a6d916e 100644 --- a/addons/website_sale_product_configurator/static/src/js/website_sale_options.js +++ b/addons/website_sale_product_configurator/static/src/js/website_sale_options.js @@ -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); + }); + }); }, }); diff --git a/addons/website_sale_product_configurator/static/tests/tours/website_sale_shop_custom_attributes_value.js b/addons/website_sale_product_configurator/static/tests/tours/website_sale_shop_custom_attributes_value.js index c1b880c04ed..ba8ee0c26a4 100644 --- a/addons/website_sale_product_configurator/static/tests/tours/website_sale_shop_custom_attributes_value.js +++ b/addons/website_sale_product_configurator/static/tests/tours/website_sale_shop_custom_attributes_value.js @@ -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) { - $('

    ').text('image variant option src changed').insertAfter('.oe_optional_products_modal .js_product:eq(1) .product-name'); + $('

    ').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))',