diff --git a/addons/sale/controllers/__init__.py b/addons/sale/controllers/__init__.py index a35a880227c..1a195b71756 100644 --- a/addons/sale/controllers/__init__.py +++ b/addons/sale/controllers/__init__.py @@ -3,4 +3,3 @@ from . import onboarding from . import portal -from . import variant diff --git a/addons/sale/controllers/variant.py b/addons/sale/controllers/variant.py deleted file mode 100644 index c8f1ed9cd0c..00000000000 --- a/addons/sale/controllers/variant.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -import json - -from odoo import http -from odoo.http import request - - -class VariantController(http.Controller): - @http.route(['/sale/get_combination_info'], type='json', auth="user", methods=['POST']) - def get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist_id, **kw): - combination = request.env['product.template.attribute.value'].browse(combination) - pricelist = self._get_pricelist(pricelist_id) - cids = request.httprequest.cookies.get('cids', str(request.env.user.company_id.id)) - allowed_company_ids = [int(cid) for cid in cids.split(',')] - ProductTemplate = request.env['product.template'].with_context(allowed_company_ids=allowed_company_ids) - if 'context' in kw: - ProductTemplate = ProductTemplate.with_context(**kw.get('context')) - product_template = ProductTemplate.browse(int(product_template_id)) - res = product_template._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist) - if 'parent_combination' in kw: - parent_combination = request.env['product.template.attribute.value'].browse(kw.get('parent_combination')) - if not combination.exists() and product_id: - product = request.env['product.product'].browse(int(product_id)) - if product.exists(): - 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 - - @http.route(['/sale/create_product_variant'], type='json', auth="user", methods=['POST']) - def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs): - return request.env['product.template'].browse(int(product_template_id)).create_product_variant(json.loads(product_template_attribute_value_ids)) - - def _get_pricelist(self, pricelist_id, pricelist_fallback=False): - return request.env['product.pricelist'].browse(int(pricelist_id or 0)) diff --git a/addons/sale_product_configurator/__manifest__.py b/addons/sale_product_configurator/__manifest__.py index 768b31b71d0..1126d2c60a4 100644 --- a/addons/sale_product_configurator/__manifest__.py +++ b/addons/sale_product_configurator/__manifest__.py @@ -17,7 +17,6 @@ It also enables the "optional products" feature. 'data': [ 'views/product_template_views.xml', 'views/sale_order_views.xml', - 'views/templates.xml', ], 'demo': [ 'data/sale_demo.xml', diff --git a/addons/website_sale/__manifest__.py b/addons/website_sale/__manifest__.py index 9663bde55a1..901d8975d26 100644 --- a/addons/website_sale/__manifest__.py +++ b/addons/website_sale/__manifest__.py @@ -41,6 +41,7 @@ 'views/website_sale_delivery_templates.xml', 'views/website_sale_menus.xml', 'views/website_sale_delivery_views.xml', + 'views/variant_templates.xml', ], 'demo': [ 'data/demo.xml', @@ -58,8 +59,7 @@ 'website_sale/static/src/scss/website_sale_delivery.scss', 'website/static/lib/multirange/multirange_custom.scss', 'sale/static/src/scss/sale_portal.scss', - 'sale/static/src/scss/product_configurator.scss', - 'sale/static/src/js/variant_mixin.js', + 'website_sale/static/src/js/sale_variant_mixin.js', 'website_sale/static/src/js/variant_mixin.js', 'website_sale/static/src/js/website_sale.js', 'website_sale/static/src/xml/website_sale.xml', @@ -80,6 +80,7 @@ 'website_sale/static/src/xml/website_sale_reorder_modal.xml', 'website_sale/static/src/js/website_sale_reorder.js', 'website_sale/static/src/js/website_sale_delivery.js', + 'website_sale/static/src/scss/product_configurator.scss', ], 'web._assets_primary_variables': [ 'website_sale/static/src/scss/primary_variables.scss', diff --git a/addons/website_sale/controllers/variant.py b/addons/website_sale/controllers/variant.py index 0464359b986..9801d73bfe9 100644 --- a/addons/website_sale/controllers/variant.py +++ b/addons/website_sale/controllers/variant.py @@ -1,33 +1,47 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import http -from odoo.http import request +# Part of Odoo. See LICENSE file for full copyright and licensing details -from odoo.addons.sale.controllers.variant import VariantController +import json + +from odoo.http import request, route, Controller -class WebsiteSaleVariantController(VariantController): - @http.route(['/sale/get_combination_info_website'], type='json', auth="public", methods=['POST'], website=True) +class WebsiteSaleVariantController(Controller): + + @route('/sale/get_combination_info_website', type='json', auth="public", methods=['POST'], website=True) def get_combination_info_website(self, product_template_id, product_id, combination, add_qty, **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') - combination = self.get_combination_info(product_template_id, product_id, combination, add_qty, request.website.get_current_pricelist(), **kw) + """Old product configurator logic, only used by frontend configurator, will be deprecated soon""" + combination = request.env['product.template.attribute.value'].browse(combination) + pricelist = request.website.get_current_pricelist() + cids = request.httprequest.cookies.get('cids', str(request.env.user.company_id.id)) + allowed_company_ids = [int(cid) for cid in cids.split(',')] + ProductTemplate = request.env['product.template'].with_context(allowed_company_ids=allowed_company_ids) + if 'context' in kw: + ProductTemplate = ProductTemplate.with_context(**kw.get('context')) + product_template = ProductTemplate.browse(int(product_template_id)) + combination_info = product_template._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist) + if 'parent_combination' in kw: + parent_combination = request.env['product.template.attribute.value'].browse(kw.get('parent_combination')) + if not combination.exists() and product_id: + product = request.env['product.product'].browse(int(product_id)) + if product.exists(): + combination = product.product_template_attribute_value_ids + combination_info.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) + }) if request.website.google_analytics_key: - combination['product_tracking_info'] = request.env['product.template'].get_google_analytics_data(combination) + combination_info['product_tracking_info'] = request.env['product.template'].get_google_analytics_data(combination_info) if request.website.product_page_image_width != 'none' and not request.env.context.get('website_sale_no_images', False): - carousel_view = request.env['ir.ui.view']._render_template('website_sale.shop_product_images', values={ - 'product': request.env['product.template'].browse(combination['product_template_id']), - 'product_variant': request.env['product.product'].browse(combination['product_id']), + combination_info['carousel'] = request.env['ir.ui.view']._render_template('website_sale.shop_product_images', values={ + 'product': request.env['product.template'].browse(combination_info['product_template_id']), + 'product_variant': request.env['product.product'].browse(combination_info['product_id']), 'website': request.env['website'].get_current_website(), }) - combination['carousel'] = carousel_view - return combination + return combination_info - @http.route(auth="public") + @route('/sale/create_product_variant', type='json', auth="public", methods=['POST']) def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs): - """Override because on the website the public user must access it.""" - return super(WebsiteSaleVariantController, self).create_product_variant(product_template_id, product_template_attribute_value_ids, **kwargs) + """Old product configurator logic, only used by frontend configurator, will be deprecated soon""" + return request.env['product.template'].browse(int(product_template_id)).create_product_variant(json.loads(product_template_attribute_value_ids)) diff --git a/addons/sale/static/src/js/variant_mixin.js b/addons/website_sale/static/src/js/sale_variant_mixin.js similarity index 99% rename from addons/sale/static/src/js/variant_mixin.js rename to addons/website_sale/static/src/js/sale_variant_mixin.js index f3097e548da..309e4b3c9f7 100644 --- a/addons/sale/static/src/js/variant_mixin.js +++ b/addons/website_sale/static/src/js/sale_variant_mixin.js @@ -1,4 +1,4 @@ -/** @odoo-module alias=sale.VariantMixin **/ +/** @odoo-module alias=website_sale.SaleVariantMixin **/ import concurrency from "web.concurrency"; import core from "web.core"; diff --git a/addons/website_sale/static/src/js/variant_mixin.js b/addons/website_sale/static/src/js/variant_mixin.js index e60af866b4e..c6d59baabbf 100644 --- a/addons/website_sale/static/src/js/variant_mixin.js +++ b/addons/website_sale/static/src/js/variant_mixin.js @@ -1,6 +1,6 @@ /** @odoo-module alias=website_sale.VariantMixin **/ -import VariantMixin from "sale.VariantMixin"; +import VariantMixin from "website_sale.SaleVariantMixin"; /** * Website behavior is slightly different from backend so we append diff --git a/addons/sale/static/src/scss/product_configurator.scss b/addons/website_sale/static/src/scss/product_configurator.scss similarity index 100% rename from addons/sale/static/src/scss/product_configurator.scss rename to addons/website_sale/static/src/scss/product_configurator.scss diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 7d38399f389..2cc5f3edc49 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -958,7 +958,7 @@ - + diff --git a/addons/website_sale/views/variant_templates.xml b/addons/website_sale/views/variant_templates.xml new file mode 100644 index 00000000000..b20d6449d3d --- /dev/null +++ b/addons/website_sale/views/variant_templates.xml @@ -0,0 +1,143 @@ + + + + + diff --git a/addons/website_sale_comparison/static/src/js/website_sale_comparison.js b/addons/website_sale_comparison/static/src/js/website_sale_comparison.js index 0052371181a..fba43d7a193 100644 --- a/addons/website_sale_comparison/static/src/js/website_sale_comparison.js +++ b/addons/website_sale_comparison/static/src/js/website_sale_comparison.js @@ -4,7 +4,7 @@ import concurrency from "web.concurrency"; import core from "web.core"; import publicWidget from "web.public.widget"; import {getCookie, setCookie} from "web.utils.cookies"; -import VariantMixin from "sale.VariantMixin"; +import VariantMixin from "website_sale.SaleVariantMixin"; import website_sale_utils from "website_sale.utils"; const cartHandlerMixin = website_sale_utils.cartHandlerMixin; @@ -305,7 +305,7 @@ publicWidget.registry.ProductComparison = publicWidget.Widget.extend(cartHandler const $form = $(ev.currentTarget); const cellIndex = $(ev.currentTarget).closest('td')[0].cellIndex; this.getCartHandlerOptions(ev); - // Override product image container for animation. + // Override product image container for animation. this.$itemImgContainer = this.$('#o_comparelist_table tr').first().find('td').eq(cellIndex); const $inputProduct = $form.find('input[type="hidden"][name="product_id"]').first(); const productId = parseInt($inputProduct.val()); diff --git a/addons/website_sale_product_configurator/__manifest__.py b/addons/website_sale_product_configurator/__manifest__.py index b4094ccba8b..b4b83f51ee7 100644 --- a/addons/website_sale_product_configurator/__manifest__.py +++ b/addons/website_sale_product_configurator/__manifest__.py @@ -11,6 +11,7 @@ 'depends': ['website_sale', 'sale_product_configurator'], 'auto_install': True, 'data': [ + 'views/templates.xml', 'views/website_sale_product_configurator_templates.xml', ], 'demo': [ @@ -18,9 +19,9 @@ ], 'assets': { 'web.assets_frontend': [ - ('before', 'website_sale/static/src/js/website_sale.js', 'sale_product_configurator/static/src/js/product_configurator_modal.js'), + ('before', 'website_sale/static/src/js/website_sale.js', 'website_sale_product_configurator/static/src/js/sale_product_configurator_modal.js'), ('before', 'website_sale/static/src/js/website_sale.js', 'website_sale_product_configurator/static/src/js/product_configurator_modal.js'), - 'sale/static/src/scss/product_configurator.scss', + 'website_sale/static/src/scss/product_configurator.scss', 'website_sale_product_configurator/static/src/scss/website_sale_options.scss', 'website_sale_product_configurator/static/src/js/website_sale_options.js', ], diff --git a/addons/website_sale_product_configurator/controllers/__init__.py b/addons/website_sale_product_configurator/controllers/__init__.py index 65a8c12013d..c64e1ca4371 100644 --- a/addons/website_sale_product_configurator/controllers/__init__.py +++ b/addons/website_sale_product_configurator/controllers/__init__.py @@ -1,3 +1,4 @@ # -*- coding: utf-8 -*- from . import main +from . import sale_product_configurator diff --git a/addons/website_sale_product_configurator/controllers/main.py b/addons/website_sale_product_configurator/controllers/main.py index c3966a88ca5..e762d2936bc 100644 --- a/addons/website_sale_product_configurator/controllers/main.py +++ b/addons/website_sale_product_configurator/controllers/main.py @@ -4,7 +4,7 @@ import json from odoo import http from odoo.http import request -from odoo.addons.sale_product_configurator.controllers.main import ProductConfiguratorController +from odoo.addons.website_sale_product_configurator.controllers.sale_product_configurator import ProductConfiguratorController from odoo.addons.website_sale.controllers import main diff --git a/addons/website_sale_product_configurator/controllers/sale_product_configurator.py b/addons/website_sale_product_configurator/controllers/sale_product_configurator.py new file mode 100644 index 00000000000..d9b01745017 --- /dev/null +++ b/addons/website_sale_product_configurator/controllers/sale_product_configurator.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import http +from odoo.http import request + + +class ProductConfiguratorController(http.Controller): + @http.route(['/sale_product_configurator/configure'], type='json', auth="user", methods=['POST']) + def configure(self, product_template_id, pricelist_id, **kw): + add_qty = float(kw.get('add_qty', 1)) + product_template = request.env['product.template'].browse(int(product_template_id)) + pricelist = self._get_pricelist(pricelist_id) + + product_combination = False + attribute_value_ids = set(kw.get('product_template_attribute_value_ids', [])) + attribute_value_ids |= set(kw.get('product_no_variant_attribute_value_ids', [])) + if attribute_value_ids: + product_combination = request.env['product.template.attribute.value'].browse( + attribute_value_ids + ).filtered( + lambda ptav: ptav.product_tmpl_id == product_template + ) # Filter out ptavs not belonging to the given template + # It happens when you change the template on an already configured line + # receiving the configured attributes data from the previous template configuration. + + if pricelist: + product_template = product_template.with_context(pricelist=pricelist.id, partner=request.env.user.partner_id) + + return request.env['ir.ui.view']._render_template( + "website_sale_product_configurator.configure", + { + 'product': product_template, + 'pricelist': pricelist, + 'add_qty': add_qty, + 'product_combination': product_combination + }, + ) + + @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_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): + pricelist = self._get_pricelist(pricelist_id) + return self._optional_product_items(product_id, pricelist, **kw) + + def _optional_product_items(self, product_id, pricelist, **kw): + add_qty = float(kw.get('add_qty', 1)) + product = request.env['product.product'].browse(int(product_id)) + + parent_combination = product.product_template_attribute_value_ids + if product.env.context.get('no_variant_attribute_values'): + # Add "no_variant" attribute values' exclusions + # They are kept in the context since they are not linked to this product variant + parent_combination |= product.env.context.get('no_variant_attribute_values') + + return request.env['ir.ui.view']._render_template("website_sale_product_configurator.optional_product_items", { + 'product': product, + 'parent_name': product.name, + 'parent_combination': parent_combination, + 'pricelist': pricelist, + 'add_qty': add_qty, + }) + + 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) + add_qty = float(kw.get('add_qty', 1)) + + no_variant_attribute_values = combination.filtered( + lambda product_template_attribute_value: product_template_attribute_value.attribute_id.create_variant == 'no_variant' + ) + if no_variant_attribute_values: + product = product.with_context(no_variant_attribute_values=no_variant_attribute_values) + + return request.env['ir.ui.view']._render_template("website_sale_product_configurator.optional_products_modal", { + 'product': product, + 'combination': combination, + 'add_qty': add_qty, + 'parent_name': product.name, + 'variant_values': variant_values, + 'pricelist': pricelist, + 'handle_stock': handle_stock, + 'already_configured': kw.get("already_configured", False), + 'mode': kw.get('mode', 'add'), + 'product_custom_attribute_values': kw.get('product_custom_attribute_values', None), + 'no_attribute': kw.get('no_attribute', False), + 'custom_attribute': kw.get('custom_attribute', False) + }) + + def _get_pricelist(self, pricelist_id, pricelist_fallback=False): + return request.env['product.pricelist'].browse(int(pricelist_id or 0)) 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 70d0f6939b8..9ee9f27bf96 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 @@ -1,6 +1,6 @@ /** @odoo-module **/ - -import { OptionalProductsModal } from "@sale_product_configurator/js/product_configurator_modal"; + +import { OptionalProductsModal } from "@website_sale_product_configurator/js/sale_product_configurator_modal"; OptionalProductsModal.include({ /** diff --git a/addons/website_sale_product_configurator/static/src/js/sale_product_configurator_modal.js b/addons/website_sale_product_configurator/static/src/js/sale_product_configurator_modal.js new file mode 100644 index 00000000000..0374330782b --- /dev/null +++ b/addons/website_sale_product_configurator/static/src/js/sale_product_configurator_modal.js @@ -0,0 +1,525 @@ +/** @odoo-module */ + +import ajax from 'web.ajax'; +import Dialog from 'web.Dialog'; +import OwlDialog from 'web.OwlDialog'; +import ServicesMixin from 'web.ServicesMixin'; +import VariantMixin from 'website_sale.SaleVariantMixin'; + +export const OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, { + events: Object.assign({}, Dialog.prototype.events, VariantMixin.events, { + 'click a.js_add, a.js_remove': '_onAddOrRemoveOption', + 'click button.js_add_cart_json': 'onClickAddCartJSON', + 'change .in_cart input.js_quantity': '_onChangeQuantity', + 'change .js_raw_price': '_computePriceTotal' + }), + /** + * Initializes the optional products modal + * + * @override + * @param {$.Element} parent The parent container + * @param {Object} params + * @param {integer} params.pricelistId + * @param {string} params.okButtonText The text to apply on the "ok" button, typically + * "Add" for the sale order and "Proceed to checkout" on the web shop + * @param {string} params.cancelButtonText same as "params.okButtonText" but + * for the cancel button + * @param {integer} params.previousModalHeight used to configure a min height on the modal-content. + * This parameter is provided by the product configurator to "cover" its modal by making + * this one big enough. This way the user can't see multiple buttons (which can be confusing). + * @param {Object} params.rootProduct The root product of the optional products window + * @param {integer} params.rootProduct.product_id + * @param {integer} params.rootProduct.quantity + * @param {Array} params.rootProduct.variant_values + * @param {Array} params.rootProduct.product_custom_attribute_values + * @param {Array} params.rootProduct.no_variant_attribute_values + */ + init: function (parent, params) { + var self = this; + + var options = Object.assign({ + size: 'large', + buttons: [{ + text: params.okButtonText, + click: this._onConfirmButtonClick, + // the o_sale_product_configurator_edit class is used for tours. + classes: 'btn-primary o_sale_product_configurator_edit' + }, { + text: params.cancelButtonText, + click: this._onCancelButtonClick + }], + technical: !params.isWebsite, + }, params || {}); + + this._super(parent, options); + + this.context = params.context; + this.rootProduct = params.rootProduct; + this.container = parent; + this.pricelistId = params.pricelistId; + this.previousModalHeight = params.previousModalHeight; + this.mode = params.mode; + this.dialogClass = 'oe_advanced_configurator_modal'; + this._productImageField = 'image_128'; + + this._opened.then(function () { + if (self.previousModalHeight) { + self.$el.closest('.modal-content').css('min-height', self.previousModalHeight + 'px'); + } + }); + }, + /** + * @override + */ + willStart: function () { + var self = this; + + var uri = this._getUri("/sale_product_configurator/show_advanced_configurator"); + var getModalContent = ajax.jsonRpc(uri, 'call', { + mode: self.mode, + product_id: self.rootProduct.product_id, + variant_values: self.rootProduct.variant_values, + product_custom_attribute_values: self.rootProduct.product_custom_attribute_values, + pricelist_id: self.pricelistId || false, + add_qty: self.rootProduct.quantity, + force_dialog: self.forceDialog, + no_attribute: self.rootProduct.no_variant_attribute_values, + custom_attribute: self.rootProduct.product_custom_attribute_values, + context: Object.assign({'quantity': self.rootProduct.quantity}, this.context), + }) + .then(function (modalContent) { + if (modalContent) { + var $modalContent = $(modalContent); + $modalContent = self._postProcessContent($modalContent); + self.$content = $modalContent; + } else { + self.trigger('options_empty'); + self.preventOpening = true; + } + }); + + var parentInit = self._super.apply(self, arguments); + return Promise.all([getModalContent, parentInit]); + }, + + /** + * This is overridden to append the modal to the provided container (see init("parent")). + * We need this to have the modal contained in the web shop product form. + * The additional products data will then be contained in the form and sent on submit. + * + * @override + */ + open: function (options) { + $('.tooltip').remove(); // remove open tooltip if any to prevent them staying when modal is opened + + var self = this; + this.appendTo($('
')).then(function () { + if (!self.preventOpening) { + self.$modal.find(".modal-body").replaceWith(self.$el); + self.$modal.attr('open', true); + self.$modal.appendTo(self.container); + const modal = new Modal(self.$modal[0], { + focus: true, + }); + modal.show(); + self._openedResolver(); + + // Notifies OwlDialog to adjust focus/active properties on owl dialogs + OwlDialog.display(self); + } + }); + if (options && options.shouldFocusButtons) { + self._onFocusControlButton(); + } + + return self; + }, + /** + * Will update quantity input to synchronize with previous window + * + * @override + */ + start: function () { + var def = this._super.apply(this, arguments); + var self = this; + + 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').toArray(); + $products.forEach((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 () { + self.triggerVariantChange(self.$el); + }); + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the list of selected products. + * The root product is added on top of the list. + * + * @returns {Array} products + * {integer} product_id + * {integer} quantity + * {Array} product_custom_variant_values + * {Array} no_variant_attribute_values + * @public + */ + getAndCreateSelectedProducts: async function () { + var self = this; + const products = []; + let productCustomVariantValues; + let noVariantAttributeValues; + for (const product of self.$modal.find('.js_product.in_cart')) { + var $item = $(product); + var quantity = parseFloat($item.find('input[name="add_qty"]').val().replace(',', '.') || 1); + var parentUniqueId = product.dataset.parentUniqueId; + var uniqueId = product.dataset.uniqueId; + productCustomVariantValues = $item.find('.custom-attribute-info').data("attribute-value") || self.getCustomVariantValues($item); + noVariantAttributeValues = $item.find('.no-attribute-info').data("attribute-value") || 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': productID, + '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 + }); + } + return products; + }, + + // ------------------------------------------ + // Private + // ------------------------------------------ + + /** + * Adds the product image and updates the product description + * based on attribute values that are either "no variant" or "custom". + * + * @private + */ + _postProcessContent: function ($modalContent) { + var productId = this.rootProduct.product_id; + $modalContent + .find('img:first') + .attr("src", "/web/image/product.product/" + productId + "/image_128"); + + if (this.rootProduct && + (this.rootProduct.product_custom_attribute_values || + this.rootProduct.no_variant_attribute_values)) { + var $productDescription = $modalContent + .find('.main_product') + .find('td.td-product_name div.text-muted.small > div:first'); + var $updatedDescription = $('
'); + $updatedDescription.append($('

', { + text: $productDescription.text() + })); + $.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.res_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') { + $updatedDescription.append($('

', { + text: this.attribute_name + ': ' + this.attribute_value_name + })); + } + }); + + $productDescription.replaceWith($updatedDescription); + } + + return $modalContent; + }, + + /** + * @private + */ + _onConfirmButtonClick: function () { + this.trigger('confirm'); + this.close(); + }, + + /** + * @private + */ + _onCancelButtonClick: function () { + this.trigger('back'); + this.close(); + }, + + /** + * Will add/remove the option, that includes: + * - Moving it to the correct DOM section + * and possibly under its parent product + * - Hiding attribute values selection and showing the quantity + * - Creating the product if it's in "dynamic" mode (see product_attribute.create_variant) + * - Updating the description based on custom/no_create attribute values + * - Removing optional products if parent product is removed + * - Computing the total price + * + * @private + * @param {MouseEvent} ev + */ + _onAddOrRemoveOption: function (ev) { + ev.preventDefault(); + var self = this; + var $target = $(ev.currentTarget); + 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"); + + var productTemplateId = $parent.find(".product_template_id").val(); + if ($target.hasClass('js_add')) { + self._onAddOption($modal, $parent, productTemplateId); + } else { + self._onRemoveOption($modal, $parent); + } + + self._computePriceTotal(); + }, + + /** + * @private + * @see _onAddOrRemoveOption + * @param {$.Element} $modal + * @param {$.Element} $parent + * @param {integer} productTemplateId + */ + _onAddOption: function ($modal, $parent, productTemplateId) { + var self = this; + var $selectOptionsText = $modal.find('.o_select_options'); + + 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); + if (productCustomVariantValues || noVariantAttributeValues) { + var $productDescription = $parent + .find('td.td-product_name div.float-start'); + + var $customAttributeValuesDescription = $('
', { + class: 'custom_attribute_values_description text-muted small' + }); + if (productCustomVariantValues.length !== 0 || noVariantAttributeValues.length !== 0) { + $customAttributeValuesDescription.append($('
')); + } + + $.each(productCustomVariantValues, function (){ + $customAttributeValuesDescription.append($('
', { + text: this.attribute_value_name + ': ' + this.custom_value + })); + }); + + $.each(noVariantAttributeValues, function (){ + if (this.is_custom !== 'True'){ + $customAttributeValuesDescription.append($('
', { + text: this.attribute_name + ': ' + this.attribute_value_name + })); + } + }); + + $productDescription.append($customAttributeValuesDescription); + } + + // 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, + $parent.find('.product_id').val(), + productTemplateId, + true + ).then(function (productId) { + $parent.find('.product_id').val(productId); + + ajax.jsonRpc(self._getUri("/sale_product_configurator/optional_product_items"), 'call', { + 'product_id': productId, + 'pricelist_id': self.pricelistId || false, + }).then(function (addedItem) { + var $addedItem = $(addedItem); + $modal.find('tr:last').after($addedItem); + + self.$el.find('input[name="add_qty"]').trigger('change'); + self.triggerVariantChange($addedItem); + + // 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) { + // no more optional products to select -> hide the header + $selectOptionsText.hide(); + } + }); + }); + }, + + /** + * @private + * @see _onAddOrRemoveOption + * @param {$.Element} $modal + * @param {$.Element} $parent + */ + _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-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(); + + $modal.find('.o_select_options').show(); + + var productUniqueId = $parent[0].dataset.uniqueId; + this._removeOptionOption($modal, productUniqueId); + + $modal.find('tr:last').after($parent); + }, + + /** + * If the removed product had optional products, remove them as well + * + * @private + * @param {$.Element} $modal + * @param {integer} optionUniqueId The removed optional product id + */ + _removeOptionOption: function ($modal, optionUniqueId) { + var self = this; + $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) { + $parent + .find('.td-product_name .product-name') + .first() + .text(combination.display_name); + + VariantMixin._onChangeCombination.apply(this, arguments); + + this._computePriceTotal(); + }, + /** + * Update price total when the quantity of a product is changed + * + * @private + * @param {MouseEvent} ev + */ + _onChangeQuantity: function (ev) { + var $product = $(ev.target.closest('tr.js_product')); + var qty = parseFloat($(ev.currentTarget).val()); + + 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()) { + this.onChangeAddQuantity(ev); + } + if ($product.hasClass('main_product')) { + this.rootProduct.quantity = qty; + } + this.trigger('update_quantity', this.rootProduct.quantity); + this._computePriceTotal(); + }, + + /** + * When a product is added or when the quantity is changed, + * we need to refresh the total price row + */ + _computePriceTotal: function () { + if (this.$modal.find('.js_price_total').length) { + var price = 0; + this.$modal.find('.js_product.in_cart').each(function () { + var quantity = parseFloat($(this).find('input[name="add_qty"]').first().val().replace(',', '.') || 1); + price += parseFloat($(this).find('.js_raw_price').html()) * quantity; + }); + + this.$modal.find('.js_price_total .oe_currency_value').text( + this._priceToStr(parseFloat(price)) + ); + } + }, + + /** + * Extension point for website_sale + * + * @private + */ + _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; + }, +}); 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 890abd554cc..0789dea0e3d 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 @@ -3,7 +3,7 @@ import ajax from "web.ajax"; import core from "web.core"; import publicWidget from "web.public.widget"; -import { OptionalProductsModal } from "@sale_product_configurator/js/product_configurator_modal"; +import { OptionalProductsModal } from "@website_sale_product_configurator/js/sale_product_configurator_modal"; import "website_sale.website_sale"; import wsUtils from "website_sale.utils"; diff --git a/addons/sale_product_configurator/views/templates.xml b/addons/website_sale_product_configurator/views/templates.xml similarity index 95% rename from addons/sale_product_configurator/views/templates.xml rename to addons/website_sale_product_configurator/views/templates.xml index 3872c8d859f..c669fc401c1 100644 --- a/addons/sale_product_configurator/views/templates.xml +++ b/addons/website_sale_product_configurator/views/templates.xml @@ -2,7 +2,7 @@ @@ -38,7 +38,7 @@
- +

@@ -57,7 +57,7 @@ }'/>

- +

This combination does not exist.

@@ -118,7 +118,7 @@ - + @@ -132,7 +132,7 @@
- +

Available Options:

- + @@ -196,12 +196,12 @@
- + - +
-