diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index a38d729d1b8..3cc0bc99fd2 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -1298,6 +1298,8 @@ class ProductTemplate(models.Model): We need to open the product configurator if the product: - is configurable (see has_configurable_attributes) - has optional products (method is extended in sale to return optional products info) + + Note: self.ensure_one() """ self.ensure_one() if self.product_variant_count == 1 and not self.has_configurable_attributes: diff --git a/addons/sale/__manifest__.py b/addons/sale/__manifest__.py index 8a0db8e50c1..729a5ae8274 100644 --- a/addons/sale/__manifest__.py +++ b/addons/sale/__manifest__.py @@ -50,7 +50,6 @@ This module contains all the common features of Sales Management and eCommerce. 'views/product_packaging_views.xml', 'views/product_views.xml', 'views/res_partner_views.xml', - 'views/variant_templates.xml', 'views/sale_onboarding_views.xml', 'views/sale_order_line_views.xml', 'views/sale_portal_templates.xml', @@ -66,7 +65,6 @@ This module contains all the common features of Sales Management and eCommerce. 'assets': { 'web.assets_backend': [ 'sale/static/src/scss/sale_onboarding.scss', - 'sale/static/src/scss/product_configurator.scss', 'sale/static/src/js/sale_progressbar_field.js', 'sale/static/src/js/tours/sale.js', 'sale/static/src/js/product_discount_field.js', diff --git a/addons/sale/views/variant_templates.xml b/addons/sale/views/variant_templates.xml deleted file mode 100644 index 059407668fe..00000000000 --- a/addons/sale/views/variant_templates.xml +++ /dev/null @@ -1,143 +0,0 @@ - - - - - diff --git a/addons/sale_product_configurator/__manifest__.py b/addons/sale_product_configurator/__manifest__.py index 1126d2c60a4..fdee0cc63d8 100644 --- a/addons/sale_product_configurator/__manifest__.py +++ b/addons/sale_product_configurator/__manifest__.py @@ -23,9 +23,7 @@ It also enables the "optional products" feature. ], 'assets': { 'web.assets_backend': [ - 'sale/static/src/js/variant_mixin.js', - 'sale_product_configurator/static/src/js/product_configurator_modal.js', - 'sale_product_configurator/static/src/js/sale_product_field.js', + 'sale_product_configurator/static/src/**/*', ], }, 'auto_install': True, diff --git a/addons/sale_product_configurator/controllers/__init__.py b/addons/sale_product_configurator/controllers/__init__.py index 5d4b25db9c0..80ee4da1c5e 100644 --- a/addons/sale_product_configurator/controllers/__init__.py +++ b/addons/sale_product_configurator/controllers/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import main diff --git a/addons/sale_product_configurator/controllers/main.py b/addons/sale_product_configurator/controllers/main.py index 8a7bd6ec17d..4408d071b25 100644 --- a/addons/sale_product_configurator/controllers/main.py +++ b/addons/sale_product_configurator/controllers/main.py @@ -1,95 +1,329 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import http -from odoo.http import request +from datetime import datetime + +from odoo.http import Controller, request, route -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) +class ProductConfiguratorController(Controller): - 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. + @route('/sale_product_configurator/get_values', type='json', auth='user') + def get_product_configurator_values( + self, + product_template_id, + quantity, + currency_id, + so_date, + product_uom_id=None, + company_id=None, + pricelist_id=None, + ptav_ids=None, + only_main_product=False, + ): + """ Return all product information needed for the product configurator. - if pricelist: - product_template = product_template.with_context(pricelist=pricelist.id, partner=request.env.user.partner_id) + :param int product_template_id: The product for which to seek information, as a + `product.template` id. + :param int quantity: The quantity of the product. + :param int currency_id: The currency of the transaction, as a `res.currency` id. + :param str so_date: The date of the `sale.order`, to compute the price at the right rate. + :param int|None product_uom_id: The unit of measure of the product, as a `uom.uom` id. + :param int|None company_id: The company to use, as a `res.company` id. + :param int|None pricelist_id: The pricelist to use, as a `product.pricelist` id. + :param recordset|None product_template_attribute_value_ids: The combination of the product, + as a `product.template.attribute + .value` recordset. + :param bool only_main_product: Whether the optional products should be included or not. + :rtype: dict + :return: A dict containing a list of products and a list of optional products information, + generated by :meth:`_get_product_information`. + """ + if company_id: + request.update_context(allowed_company_ids=[company_id]) + product_template = request.env['product.template'].browse(product_template_id) - return request.env['ir.ui.view']._render_template( - "sale_product_configurator.configure", + if ptav_ids: + combination = request.env['product.template.attribute.value'].browse(ptav_ids) + else: + combination = product_template._get_first_possible_combination() + + return dict( + products=[ + self._get_product_information( + product_template, + combination, + currency_id, + so_date, + quantity=quantity, + product_uom_id=product_uom_id, + pricelist_id=pricelist_id, + ) + ], + optional_products=[ + dict( + **self._get_product_information( + optional_product_template, + optional_product_template._get_first_possible_combination( + parent_combination=combination + ), + currency_id, + so_date, + # giving all the ptav of the parent product to get all the exclusions + parent_combination=product_template.attribute_line_ids.\ + product_template_value_ids, + pricelist_id=pricelist_id, + ), + parent_product_tmpl_ids=[product_template.id], + ) for optional_product_template in product_template.optional_product_ids + ] if not only_main_product else [] + ) + + @route('/sale_product_configurator/create_product', type='json', auth='user') + def sale_product_configurator_create_product(self, product_template_id, combination): + """ Create the product when there is a dynamic attribute in the combination. + + :param int product_template_id: The product for which to seek information, as a + `product.template` id. + :param recordset combination: The combination of the product, as a + `product.template.attribute.value` recordset. + :rtype: int + :return: The product created, as a `product.product` id. + """ + product_template = request.env['product.template'].browse(product_template_id) + combination = request.env['product.template.attribute.value'].browse(combination) + product = product_template._create_product_variant(combination) + return product.id + + @route('/sale_product_configurator/update_combination', type='json', auth='user') + def sale_product_configurator_update_combination( + self, + product_template_id, + combination, + currency_id, + so_date, + quantity, + product_uom_id=None, + company_id=None, + pricelist_id=None, + ): + """ Return the updated combination information. + + :param int product_template_id: The product for which to seek information, as a + `product.template` id. + :param recordset combination: The combination of the product, as a + `product.template.attribute.value` recordset. + :param int currency_id: The currency of the transaction, as a `res.currency` id. + :param str so_date: The date of the `sale.order`, to compute the price at the right rate. + :param int quantity: The quantity of the product. + :param int|None product_uom_id: The unit of measure of the product, as a `uom.uom` id. + :param int|None company_id: The company to use, as a `res.company` id. + :param int|None pricelist_id: The pricelist to use, as a `product.pricelist` id. + :rtype: dict + :return: Basic informations about a product, generated by + :meth:`_get_basic_product_information`. + """ + if company_id: + request.update_context(allowed_company_ids=[company_id]) + product_template = request.env['product.template'].browse(product_template_id) + pricelist = request.env['product.pricelist'].browse(pricelist_id) + product_uom = request.env['uom.uom'].browse(product_uom_id) + currency = request.env['res.currency'].browse(currency_id) + combination = request.env['product.template.attribute.value'].browse(combination) + product = product_template._get_variant_for_combination(combination) + + return self._get_basic_product_information( + product or product_template, + pricelist, + combination, + quantity=quantity, + uom=product_uom, + currency=currency, + date=datetime.fromisoformat(so_date), + ) + + @route('/sale_product_configurator/get_optional_products', type='json', auth='user') + def sale_product_configurator_get_optional_products( + self, + product_template_id, + combination, + parent_combination, + currency_id, + so_date, + company_id=None, + pricelist_id=None, + ): + """ Return information about optional products for the given `product.template`. + + :param int product_template_id: The product for which to seek information, as a + `product.template` id. + :param recordset combination: The combination of the product, as a + `product.template.attribute.value` recordset. + :param recordset parent_combination: The combination of the parent product, as a + `product.template.attribute.value` recordset. + :param int currency_id: The currency of the transaction, as a `res.currency` id. + :param str so_date: The date of the `sale.order`, to compute the price at the right rate. + :param int|None company_id: The company to use, as a `res.company` id. + :param int|None pricelist_id: The pricelist to use, as a `product.pricelist` id. + :rtype: [dict] + :return: A list of optional products information, generated by + :meth:`_get_product_information`. + """ + if company_id: + request.update_context(allowed_company_ids=[company_id]) + product_template = request.env['product.template'].browse(product_template_id) + parent_combination = request.env['product.template.attribute.value'].browse( + parent_combination + combination + ) + return [ + dict( + **self._get_product_information( + optional_product_template, + optional_product_template._get_first_possible_combination( + parent_combination=parent_combination + ), + currency_id, + so_date, + parent_combination=parent_combination, + pricelist_id=pricelist_id, + ), + parent_product_tmpl_ids=[product_template.id], + ) for optional_product_template in product_template.optional_product_ids + ] + + def _get_product_information( + self, + product_template, + combination, + currency_id, + so_date, + quantity=1, + product_uom_id=None, + pricelist_id=None, + parent_combination=None, + ): + """ Return complete information about a product. + + :param recordset product_template: The product for which to seek information, as a + `product.template` record. + :param recordset combination: The combination of the product, as a + `product.template.attribute.value` recordset. + :param int currency_id: The currency of the transaction, as a `res.currency` id. + :param str so_date: The date of the `sale.order`, to compute the price at the right rate. + :param int quantity: The quantity of the product. + :param int|None product_uom_id: The unit of measure of the product, as a `uom.uom` id. + :param int|None pricelist_id: The pricelist to use, as a `product.pricelist` id. + :param recordset|None parent_combination: The combination of the parent product, as a + `product.template.attribute.value` recordset. + :rtype: dict + :return: A dict with the following structure: { - 'product': product_template, - 'pricelist': pricelist, - 'add_qty': add_qty, - 'product_combination': product_combination - }, + 'product_tmpl_id': int, + 'id': int, + 'description_sale': str|False, + 'display_name': str, + 'price': float, + 'quantity': int + 'attribute_line': [{ + 'id': int + 'attribute': { + 'id': int + 'name': str + 'display_type': str + }, + 'attribute_value': [{ + 'id': int, + 'name': str, + 'price_extra': float, + 'html_color': str|False, + 'is_custom': bool + }], + 'selected_attribute_id': int, + }], + 'exclusions': dict, + 'archived_combination': dict, + 'parent_exclusions': dict, + } + """ + pricelist = request.env['product.pricelist'].browse(pricelist_id) + product_uom = request.env['uom.uom'].browse(product_uom_id) + currency = request.env['res.currency'].browse(currency_id) + product = product_template._get_variant_for_combination(combination) + attribute_exclusions = product_template._get_attribute_exclusions( + parent_combination=parent_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("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' + return dict( + product_tmpl_id=product_template.id, + **self._get_basic_product_information( + product or product_template, + pricelist, + combination, + quantity=quantity, + uom=product_uom, + currency=currency, + date=datetime.fromisoformat(so_date), + ), + quantity=quantity, + attribute_lines=[dict( + id=ptal.id, + attribute=dict(**ptal.attribute_id.read(['id', 'name', 'display_type'])[0]), + attribute_values=[dict( + **ptav.read(['name', 'html_color', 'is_custom'])[0], + price_extra=ptav.currency_id._convert( + ptav.price_extra, + currency, + request.env.company, + datetime.fromisoformat(so_date).date(), + ), + ) for ptav in ptal.product_template_value_ids], + selected_attribute_value_id=combination.filtered( + lambda c: ptal in c.attribute_line_id + ).id, + create_variant=ptal.attribute_id.create_variant, + ) for ptal in product_template.attribute_line_ids], + exclusions=attribute_exclusions['exclusions'], + archived_combinations=attribute_exclusions['archived_combinations'], + parent_exclusions=attribute_exclusions['parent_exclusions'], ) - 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("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_basic_product_information(self, product_or_template, pricelist, combination, **kwargs): + """ Return basic information about a product - def _get_pricelist(self, pricelist_id, pricelist_fallback=False): - return request.env['product.pricelist'].browse(int(pricelist_id or 0)) + :param recordset product_or_template: The product for which to seek information, as a + `product.product` or `product.template` record. + :param recordset|None pricelist: The pricelist to use, as a `product.pricelist` record. + :param recordset combination: The combination of the product, as a + `product.template.attribute.value` recordset. + :param dict kwargs: Locally unused data passed to `_get_product_price` + :rtype: dict + :return: A dict with the following structure:: + { + 'id': int, # if product_or_template is a record of `product.product`. + 'description_sale': str|False, + 'display_name': str, + 'price': float, + 'quantity': int, + } + """ + basic_information = dict( + **product_or_template.read(['description_sale', 'display_name'])[0] + ) + # If the product is a template, check the combination to compute the name to take dynamic + # and no_variant attributes into account. Also, drop the id which was auto-included by the + # search but isn't relevant since it is supposed to be the id of a `product.product` record. + if not product_or_template.is_product_variant: + basic_information.pop('id') + combination_name = combination._get_combination_name() + if combination_name: + basic_information.update( + display_name=f"{basic_information['display_name']} ({combination_name})" + ) + return dict( + **basic_information, + price=pricelist._get_product_price( + product_or_template.with_context( + **product_or_template._get_product_price_context(combination) + ), + **kwargs, + ), + ) diff --git a/addons/sale_product_configurator/static/src/js/badge_extra_price/badge_extra_price.js b/addons/sale_product_configurator/static/src/js/badge_extra_price/badge_extra_price.js new file mode 100644 index 00000000000..15111290944 --- /dev/null +++ b/addons/sale_product_configurator/static/src/js/badge_extra_price/badge_extra_price.js @@ -0,0 +1,21 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; +import { formatMonetary } from "@web/views/fields/formatters"; + +export class BadgeExtraPrice extends Component { + static template = "product.badge_extra_price"; + static props = { + price: Number, + currencyId: Number, + }; + + /** + * Return the price, in the format of the given currency. + * + * @return {String} - The price, in the format of the given currency. + */ + getFormattedPrice() { + return formatMonetary( Math.abs(this.props.price), {currencyId: this.props.currencyId}); + } +} diff --git a/addons/sale_product_configurator/static/src/js/badge_extra_price/badge_extra_price.scss b/addons/sale_product_configurator/static/src/js/badge_extra_price/badge_extra_price.scss new file mode 100644 index 00000000000..cd34f3f03cd --- /dev/null +++ b/addons/sale_product_configurator/static/src/js/badge_extra_price/badge_extra_price.scss @@ -0,0 +1,9 @@ +.o_product_badge_extra_price { + display: inline-block; + width: 1rem; + height: 1rem; + color: map-get($theme-colors, 'primary'); + background: white; + line-height: 1rem; + border-radius: 50%; +} diff --git a/addons/sale_product_configurator/static/src/js/badge_extra_price/badge_extra_price.xml b/addons/sale_product_configurator/static/src/js/badge_extra_price/badge_extra_price.xml new file mode 100644 index 00000000000..130a9d58d3d --- /dev/null +++ b/addons/sale_product_configurator/static/src/js/badge_extra_price/badge_extra_price.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/addons/sale_product_configurator/static/src/js/product/product.js b/addons/sale_product_configurator/static/src/js/product/product.js new file mode 100644 index 00000000000..9e449ba446e --- /dev/null +++ b/addons/sale_product_configurator/static/src/js/product/product.js @@ -0,0 +1,67 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; +import { formatMonetary } from "@web/views/fields/formatters"; +import { + ProductTemplateAttributeLine as PTAL +} from "../product_template_attribute_line/product_template_attribute_line"; + +export class Product extends Component { + static components = { PTAL }; + static template = "sale_product_configurator.product"; + static props = { + id: { type: Number, optional: true }, + product_tmpl_id: Number, + display_name: String, + description_sale: [Boolean, String], // backend sends 'false' when there is no description + price: Number, + quantity: Number, + attribute_lines: Object, + optional: Boolean, + imageURL: { type: String, optional: true }, + archived_combinations: Array, + exclusions: Object, + parent_exclusions: Object, + parent_product_tmpl_ids: { type: Array, element: Number, optional: true }, + }; + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Increase the quantity of the product in the state. + */ + increaseQuantity() { + this.env.setQuantity(this.props.product_tmpl_id, this.props.quantity+1); + } + + /** + * Set the quantity of the product in the state. + * + * @param {Event} event + */ + setQuantity(event) { + this.env.setQuantity(this.props.product_tmpl_id, parseFloat(event.target.value)); + } + + /** + * Decrease the quantity of the product in the state. + */ + decreaseQuantity() { + this.env.setQuantity(this.props.product_tmpl_id, this.props.quantity-1); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Return the price, in the format of the given currency. + * + * @return {String} - The price, in the format of the given currency. + */ + getFormattedPrice() { + return formatMonetary(this.props.price, {currencyId: this.env.currencyId}); + } +} diff --git a/addons/sale_product_configurator/static/src/js/product/product.scss b/addons/sale_product_configurator/static/src/js/product/product.scss new file mode 100644 index 00000000000..0f50115c1b4 --- /dev/null +++ b/addons/sale_product_configurator/static/src/js/product/product.scss @@ -0,0 +1,47 @@ +.o_sale_product_configurator_img { + width: 100px; + max-height: 240px; +} + +.o_sale_product_configurator_qty { + width: 200px; + + .input-group { + display: inline-flex; + } + + div { + width: initial; // We don't want the quantity form to be full-width + + .btn, input { + border-color: $input-border-color; + } + + input { + // Needs !important because themes customize btns' padding directly + // rather than change '$input-btn-padding-X' (shared with inputs). + height: auto !important; + max-width: 4rem; + text-align: center; + //removing input field=number arrows as their size might + //change depending on browser default styling and shift input's position + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + &[type=number] { + -moz-appearance: textfield; + } + } + } +} + +.o_sale_product_configurator_name { + word-wrap: break-word; + min-width: 140px; +} + +.o_sale_product_configurator_price { + width: 120px; +} diff --git a/addons/sale_product_configurator/static/src/js/product/product.xml b/addons/sale_product_configurator/static/src/js/product/product.xml new file mode 100644 index 00000000000..3862300e7dc --- /dev/null +++ b/addons/sale_product_configurator/static/src/js/product/product.xml @@ -0,0 +1,70 @@ + + + + + Product Image + Product Image + + +
+ +
+
+ + + + + +
+ + + +
+ + + + + + + +

Option not available

+
+
+ + + +
+ + + diff --git a/addons/sale_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.js b/addons/sale_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.js new file mode 100644 index 00000000000..6abf9c3e729 --- /dev/null +++ b/addons/sale_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.js @@ -0,0 +1,454 @@ +/** @odoo-module */ + +import { Component, onWillStart, useState, useSubEnv } from "@odoo/owl"; +import { Dialog } from '@web/core/dialog/dialog'; +import { ProductList } from "../product_list/product_list"; +import { useService } from "@web/core/utils/hooks"; + +export class ProductConfiguratorDialog extends Component { + static components = { Dialog, ProductList}; + static template = 'sale_product_configurator.dialog'; + static props = { + productTemplateId: Number, + ptavIds: { type: Array, element: Number }, + customAttributeValues: { + type: Array, + element: Object, + shape: { + ptavId: Number, + value: String, + } + }, + quantity: Number, + productUOMId: { type: Number, optional: true }, + companyId: { type: Number, optional: true }, + pricelistId: { type: Number, optional: true }, + currencyId: Number, + soDate: String, + edit: { type: Boolean, optional: true }, + save: Function, + discard: Function, + close: Function, // This is the close from the env of the Dialog Component + }; + static defaultProps = { + edit: false, + } + + setup() { + this.title = this.env._t("Configure"); + this.rpc = useService("rpc"); + this.state = useState({ + products: [], + optionalProducts: [], + }); + + useSubEnv({ + mainProductTmplId: this.props.productTemplateId, + currencyId: this.props.currencyId, + addProduct: this._addProduct.bind(this), + removeProduct: this._removeProduct.bind(this), + setQuantity: this._setQuantity.bind(this), + updateProductTemplateSelectedPTAV: this._updateProductTemplateSelectedPTAV.bind(this), + updatePTAVCustomValue: this._updatePTAVCustomValue.bind(this), + isPossibleCombination: this._isPossibleCombination, + }); + + onWillStart(async () => { + const { products, optional_products } = await this._loadData(this.props.edit); + this.state.products = products; + this.state.optionalProducts = optional_products; + for (const customValue of this.props.customAttributeValues) { + this._updatePTAVCustomValue( + this.env.mainProductTmplId, + customValue.ptavId, + customValue.value + ); + } + this._checkExclusions(this.state.products[0]); + }); + } + + //-------------------------------------------------------------------------- + // Data Exchanges + //-------------------------------------------------------------------------- + + async _loadData(onlyMainProduct) { + return this.rpc('/sale_product_configurator/get_values', { + product_template_id: this.props.productTemplateId, + quantity: this.props.quantity, + currency_id: this.props.currencyId, + so_date: this.props.soDate, + product_uom_id: this.props.productUOMId, + company_id: this.props.companyId, + pricelist_id: this.props.pricelistId, + ptav_ids: this.props.ptavIds, + only_main_product: onlyMainProduct, + }); + } + + async _createProduct(product) { + return this.rpc('/sale_product_configurator/create_product', { + product_template_id: product.product_tmpl_id, + combination: this._getCombination(product), + }); + } + + async _updateCombination(product, quantity) { + return this.rpc('/sale_product_configurator/update_combination', { + product_template_id: product.product_tmpl_id, + combination: this._getCombination(product), + currency_id: this.props.currencyId, + so_date: this.props.soDate, + quantity: quantity, + product_uom_id: this.props.productUOMId, + company_id: this.props.companyId, + pricelist_id: this.props.pricelistId, + }); + } + + async _getOptionalProducts(product) { + return this.rpc('/sale_product_configurator/get_optional_products', { + product_template_id: product.product_tmpl_id, + combination: this._getCombination(product), + parent_combination: this._getParentsCombination(product), + currency_id: this.props.currencyId, + so_date: this.props.soDate, + company_id: this.props.companyId, + pricelist_id: this.props.pricelistId, + }); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Add the product to the list of products and fetch his optional products. + * + * @param {Number} productTmplId - The product template id, as a `product.template` id. + */ + async _addProduct(productTmplId) { + const index = this.state.optionalProducts.findIndex( + p => p.product_tmpl_id === productTmplId + ); + if (index >= 0) { + this.state.products.push(...this.state.optionalProducts.splice(index, 1)); + // Fetch optional product from the server with the parent combination. + const product = this._findProduct(productTmplId); + const newOptionalProducts = await this._getOptionalProducts(product); + for(const newOptionalProductDict of newOptionalProducts) { + // If the optional product is already in the list, add the id of the parent product + // template in his list of `parent_product_tmpl_ids` instead of adding a second time + // the product. + const newProduct = this._findProduct(newOptionalProductDict.product_tmpl_id); + if (newProduct) { + newOptionalProducts.pop(newOptionalProductDict); + newProduct.parent_product_tmpl_ids.push(productTmplId); + } + } + if (newOptionalProducts) this.state.optionalProducts.push(...newOptionalProducts); + } + } + + /** + * Remove the product and his optional products from the list of products. + * + * @param {Number} productTmplId - The product template id, as a `product.template` id. + */ + _removeProduct(productTmplId) { + const index = this.state.products.findIndex(p => p.product_tmpl_id === productTmplId); + if (index >= 0) { + this.state.optionalProducts.push(...this.state.products.splice(index, 1)); + for (const childProduct of this._getChildProducts(productTmplId)) { + // Optional products might have multiple parents so we don't want to remove them if + // any of their parents are still on the list of products. + childProduct.parent_product_tmpl_ids = childProduct.parent_product_tmpl_ids.filter( + id => id !== productTmplId + ); + if (!childProduct.parent_product_tmpl_ids.length) { + this._removeProduct(childProduct.product_tmpl_id); + this.state.optionalProducts.splice( + this.state.optionalProducts.findIndex( + p => p.product_tmpl_id === childProduct.product_tmpl_id + ), 1 + ); + } + } + } + } + + /** + * Set the quantity of the product to a given value. + * + * If the value is less than or equal to zero, the product is removed from the product list + * instead, unless it is the main product, in which case the changes are ignored. + * + * @param {Number} productTmplId - The product template id, as a `product.template` id. + * @param {Number} quantity - The new quantity of the product. + */ + async _setQuantity(productTmplId, quantity) { + if (quantity <= 0) { + if (productTmplId === this.env.mainProductTmplId) { + const product = this._findProduct(productTmplId); + const { price } = await this._updateCombination(product, 1); + product.quantity = 1; + product.price = parseFloat(price); + return; + }; + this._removeProduct(productTmplId); + } else { + const product = this._findProduct(productTmplId); + const { price } = await this._updateCombination(product, quantity); + product.quantity = quantity; + product.price = parseFloat(price); + } + } + + /** + * Change the value of `selected_attribute_value_id` on the given PTAL in the product. + * + * @param {Number} productTmplId - The product template id, as a `product.template` id. + * @param {Number} ptalId - The PTAL id, as a `product.template.attribute.line` id. + * @param {Number} ptavId - The PTAV id, as a `product.template.attribute.value` id. + */ + async _updateProductTemplateSelectedPTAV(productTmplId, ptalId, ptavId) { + const product = this._findProduct(productTmplId); + product.attribute_lines.find( + ptal => ptal.id === ptalId + ).selected_attribute_value_id = parseInt(ptavId); + this._checkExclusions(product); + if (this._isPossibleCombination(product)) { + const updatedValues = await this._updateCombination(product, product.quantity); + Object.assign(product, updatedValues); + } + } + + /** + * Set the custom value for a given custom PTAV. + * + * @param {Number} productTmplId - The product template id, as a `product.template` id. + * @param {Number} ptavId - The PTAV id, as a `product.template.attribute.value` id. + * @param {String} customValue - The custom value. + */ + _updatePTAVCustomValue(productTmplId, ptavId, customValue) { + const product = this._findProduct(productTmplId); + product.attribute_lines.find( + ptal => ptal.selected_attribute_value_id === ptavId + ).customValue = customValue; + } + + /** + * Check the exclusions of a given product and his child. + * + * @param {Object} product - The product for which to check the exclusions. + */ + _checkExclusions(product) { + const combination = this._getCombination(product); + const exclusions = product.exclusions; + const parentExclusions = product.parent_exclusions; + const archivedCombinations = product.archived_combinations; + const parentCombination = this._getParentsCombination(product); + const childProducts = this._getChildProducts(product.product_tmpl_id) + const ptavList = product.attribute_lines.flat().flatMap(ptal => ptal.attribute_values) + ptavList.map(ptav => ptav.excluded = false); // Reset all the values + + if (exclusions) { + for(const ptavId of combination) { + for(const excludedPtavId of exclusions[ptavId]) { + ptavList.find(ptav => ptav.id === excludedPtavId).excluded = true; + } + } + } + if (parentCombination) { + for(const ptavId of parentCombination) { + for(const excludedPtavId of (parentExclusions[ptavId]||[])) { + ptavList.find(ptav => ptav.id === excludedPtavId).excluded = true; + } + } + } + if (archivedCombinations) { + for(const excludedCombination of archivedCombinations) { + const ptavCommon = excludedCombination.filter((ptav) => combination.includes(ptav)); + if (ptavCommon.length === combination.length) { + for(const excludedPtavId of ptavCommon) { + ptavList.find(ptav => ptav.id === excludedPtavId).excluded = true; + } + } else if (ptavCommon.length === (combination.length - 1)) { + // In this case we only need to disable the remaining ptav + const disabledPtavId = excludedCombination.find( + (ptav) => !combination.includes(ptav) + ); + ptavList.find(ptav => ptav.id === disabledPtavId).excluded = true; + } + } + } + for(const optionalProductTmpl of childProducts) { + this._checkExclusions(optionalProductTmpl); + } + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Return the product given his template id. + * + * @param {Number} productTmplId - The product template id, as a `product.template` id. + * @return {Object} - The product. + */ + _findProduct(productTmplId) { + // The product might be in either of the two lists `products` or `optional_products`. + return this.state.products.find(p => p.product_tmpl_id === productTmplId) || + this.state.optionalProducts.find(p => p.product_tmpl_id === productTmplId); + } + + /** + * Return the list of dependents products for a given product. + * + * @param {Number} productTmplId - The product template id for which to find his children, as a + * `product.template` id. + * @return {Array} - The list of dependents products. + */ + _getChildProducts(productTmplId) { + return [ + ...this.state.products.filter(p => p.parent_product_tmpl_ids?.includes(productTmplId)), + ...this.state.optionalProducts.filter(p => p.parent_product_tmpl_ids?.includes(productTmplId)) + ] + } + + /** + * Return the selected PTAV of the product, as a list of `product.template.attribute.value` id. + * + * @param {Object} product - The product for which to find the combination. + * @return {Array} - The combination of the product. + */ + _getCombination(product) { + return product.attribute_lines.map(ptal => ptal.selected_attribute_value_id); + } + + /** + * Return the selected PTAV of all the product parents, as a list of + * `product.template.attribute.value` id. + * + * @param {Object} product - The product for which to find the combination. + * @return {Array} - The combination of the product. + */ + _getParentsCombination(product) { + let parentsCombination = []; + for(const parentProductTmplId of product.parent_product_tmpl_ids || []) { + parentsCombination.push(this._getCombination(this._findProduct(parentProductTmplId))); + } + return parentsCombination.flat(); + } + + /** + * Check if a product has a valid combination. + * + * @param {Object} product - The product for which to check the combination. + * @return {Boolean} - Whether the combination is valid or not. + */ + _isPossibleCombination(product) { + return product.attribute_lines.every(ptal => !ptal.attribute_values.find( + ptav => ptav.id === ptal.selected_attribute_value_id + ).excluded); + } + + /** + * Check if all the products selected have a valid combination. + * + * @return {Boolean} - Whether all the products selected have a valid combination or not. + */ + isPossibleConfiguration() { + return [...this.state.products].every( + p => this._isPossibleCombination(p) + ); + } + + /** + * Serialize the product into a format understandable by `sale.order.line`. + * + * @param {Object} product - The product to serialize. + * @return {Object} - The serialized product. + */ + _serializeProduct(product) { + let serializedProduct = { + product_id: [product.id, product.display_name], + product_uom_qty: product.quantity, + } + + // handle custom values & no variants + let customValuesCommands = [{ operation: "DELETE_ALL" }]; + for (const ptal of product.attribute_lines) { + const selectedCustomPTAV = ptal.attribute_values.find( + ptav => ptav.is_custom && ptav.id === ptal.selected_attribute_value_id + ); + if (selectedCustomPTAV) customValuesCommands.push({ + operation: "CREATE", + context: [ + { + default_custom_product_template_attribute_value_id: selectedCustomPTAV.id, + default_custom_value: ptal.customValue, + }, + ], + }); + } + serializedProduct.product_custom_attribute_value_ids = { + operation: "MULTI", + commands: customValuesCommands, + }; + + let noVariantCommands = [{ operation: "DELETE_ALL" }]; + const noVariantPTAVIds = product.attribute_lines.filter( + ptal => ptal.create_variant === "no_variant" + ).map(ptal => { return {id: ptal.selected_attribute_value_id}}); + if (noVariantPTAVIds.length) noVariantCommands.push({ + operation: "ADD_M2M", + ids: noVariantPTAVIds, + }); + serializedProduct.product_no_variant_attribute_value_ids = { + operation: "MULTI", + commands: noVariantCommands, + }; + + return serializedProduct; + } + + /** + * Confirm the current combination(s). + * + * @return {undefined} + */ + async onConfirm() { + if (!this.isPossibleConfiguration()) return; + // Create the products with dynamic attributes + for (const product of this.state.products) { + if ( + !product.id && + product.attribute_lines.some(ptal => ptal.create_variant === "dynamic") + ) { + const productId = await this._createProduct(product); + product.id = parseInt(productId); + } + } + await this.props.save( + this._serializeProduct(this.state.products.find( + p => p.product_tmpl_id === this.env.mainProductTmplId + )), + this.state.products.filter( + p => p.product_tmpl_id !== this.env.mainProductTmplId + ).map(p => this._serializeProduct(p)), + ); + this.props.close(); + } + + /** + * Discard the modal. + */ + onDiscard() { + if (!this.props.edit) { + this.props.discard(); // clear the line + } + this.props.close(); + } +} diff --git a/addons/sale_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.xml b/addons/sale_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.xml new file mode 100644 index 00000000000..4e8f1017a78 --- /dev/null +++ b/addons/sale_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + 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 deleted file mode 100644 index e03b42fcc42..00000000000 --- a/addons/sale_product_configurator/static/src/js/product_configurator_modal.js +++ /dev/null @@ -1,525 +0,0 @@ -/** @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 'sale.VariantMixin'; - -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/sale_product_configurator/static/src/js/product_list/product_list.js b/addons/sale_product_configurator/static/src/js/product_list/product_list.js new file mode 100644 index 00000000000..4592ee06d0a --- /dev/null +++ b/addons/sale_product_configurator/static/src/js/product_list/product_list.js @@ -0,0 +1,31 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; +import { formatMonetary } from "@web/views/fields/formatters"; +import { Product } from "../product/product"; + +export class ProductList extends Component { + static components = { Product }; + static template = "saleProductConfigurator.productList"; + static props = { + products: Array, + areProductsOptional: { type: Boolean, optional: true }, + }; + static defaultProps = { + areProductsOptional: false, + }; + + /** + * Return the total of the product in the list, in the currency of the `sale.order`. + * + * @return {String} - The sum of all items in the list, in the currency of the `sale.order`. + */ + getFormattedTotal() { + return formatMonetary( + this.props.products.reduce( + (totalPrice, product) => totalPrice + product.price * product.quantity, 0 + ), + {currencyId: this.env.currencyId}, + ) + } +} diff --git a/addons/sale_product_configurator/static/src/js/product_list/product_list.xml b/addons/sale_product_configurator/static/src/js/product_list/product_list.xml new file mode 100644 index 00000000000..551b004461e --- /dev/null +++ b/addons/sale_product_configurator/static/src/js/product_list/product_list.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + +
Product + Quantity + + Price +

Available Options:

+ Total: + +
+
+
diff --git a/addons/sale_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.js b/addons/sale_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.js new file mode 100644 index 00000000000..d9c59707a76 --- /dev/null +++ b/addons/sale_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.js @@ -0,0 +1,132 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; +import { formatMonetary } from "@web/views/fields/formatters"; +import { BadgeExtraPrice } from "../badge_extra_price/badge_extra_price"; + +export class ProductTemplateAttributeLine extends Component { + static components = { BadgeExtraPrice }; + static template = "saleProductConfigurator.ptal"; + static props = { + productTmplId: Number, + id: Number, + attribute: { + type: Object, + shape: { + id: Number, + name: String, + display_type: { + type: String, + validate: type => ["color", "pills", "radio", "select"].includes(type), + }, + }, + }, + attribute_values: { + type: Array, + element: { + type: Object, + shape: { + id: Number, + name: String, + html_color: [Boolean, String], // backend sends 'false' when there is no color + is_custom: Boolean, + price_extra: Number, + excluded: { type: Boolean, optional: true }, + }, + }, + }, + selected_attribute_value_id: Number, + create_variant: { + type: String, + validate: type => ["always", "dynamic", "no_variant"].includes(type), + }, + customValue: { type: String, optional: true }, + }; + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Update the selected PTAV in the state. + * + * @param {Event} event + */ + updateSelectedPTAV(event) { + this.env.updateProductTemplateSelectedPTAV( + this.props.productTmplId, this.props.id, event.target.value + ); + } + + /** + * Update in the state the custom value of the selected PTAV. + * + * @param {Event} event + */ + updateCustomValue(event) { + this.env.updatePTAVCustomValue( + this.props.productTmplId, this.props.selected_attribute_value_id, event.target.value + ); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Return template name to use by checking the display type in the props. + * + * Each attribute line can have one of this four display types: + * - 'Color' : Display each attribute as a circle filled with said color. + * - 'Pills' : Display each attribute as a rectangle-shaped element. + * - 'Radio' : Display each attribute as a radio element. + * - 'Select' : Display each attribute in a selection tag. + * + * @return {String} - The template name to use. + */ + getPTAVTemplate() { + switch(this.props.attribute.display_type) { + case 'color': + return 'saleProductConfigurator.ptav-color'; + case 'pills': + return 'saleProductConfigurator.ptav-pills'; + case 'radio': + return 'saleProductConfigurator.ptav-radio'; + case 'select': + return 'saleProductConfigurator.ptav-select'; + } + } + + /** + * Return the name of the PTAV + * + * In the selection HTML tag, it is impossible to show the component `BadgeExtraPrice`. Append + * the extra price to the name to ensure that the extra price will be shown. + * Note: used in `saleProductConfigurator.ptav-select`. + * + * @param {Object} ptav - The attribute, as a `product.template.attribute.value` summary dict. + * @return {String} - The name of the PTAV. + */ + getPTAVSelectName(ptav) { + if (ptav.price_extra) { + const sign = ptav.price_extra > 0 ? '+' : '-'; + const price = formatMonetary( + Math.abs(ptav.price_extra),{currencyId: this.env.currencyId} + ); + return ptav.name +" ("+ sign + " " + price + ")"; + } else { + return ptav.name; + } + } + + /** + * Check if the selected ptav is custom or not. + * + * @return {Boolean} - Whether the selected ptav is custom or not. + */ + isSelectedPTAVCustom() { + return this.props.attribute_values.find( + ptav => this.props.selected_attribute_value_id === ptav.id + ).is_custom; + } + } diff --git a/addons/sale_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.scss b/addons/sale_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.scss new file mode 100644 index 00000000000..8c75f7fcb5e --- /dev/null +++ b/addons/sale_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.scss @@ -0,0 +1,98 @@ +.o_sale_product_configurator_ptal_name { + display: flex; + align-items: center; + font-size: 0.9rem; + text-transform: uppercase; + padding-bottom: 0.5rem; + + &:after { + content: ''; + margin-left: $spacer; + flex-grow: 1; + border-bottom: 1px solid map-get($grays, '400'); + } +} + +.o_sale_product_configurator_ptav_color { + position: relative; + display: inline-block; + border: 5px solid $input-border-color; + border-radius: 50%; + text-align: center; + transition: $input-transition; + + @include o-field-pointer(); + + &:before { + content: ""; + display: block; + @include o-position-absolute(-3px, -3px, -3px, -3px); + border: 4px solid white; + border-radius: 50%; + box-shadow: inset 0 0 3px rgba(black, 0.3); + } + + input { + margin: 8px; + height: 13px; + width: 13px; + opacity: 0; + } + + &.active { + border: 5px solid map-get($theme-colors, 'primary'); + } + + &.custom_value { + background-image: linear-gradient(to bottom right, #FF0000, #FFF200, #1E9600); + } + + &.transparent { + background-image: url(/web/static/img/transparent.png); + } + + &.css_not_available { + opacity: 1; + + &:after { + content: ""; + @include o-position-absolute(-5px, -5px, -5px, -5px); + border: 2px solid map-get($theme-colors, 'danger'); + border-radius: 50%; + background: str-replace(url("data:image/svg+xml;utf8,"), "#", "%23") ; + background-position: center; + background-repeat: no-repeat; + } + } +} + +.o_sale_product_configurator_ptav_pills { + padding: $spacer/2 $spacer; + margin-right: 0.2rem; + border: none; + cursor: default !important; + + &.btn.active { + background-color: map-get($theme-colors, 'primary'); + } + &:not(.active) { + color: map-get($grays, '600'); + background-color: map-get($grays, '200'); + } + + input { + -moz-appearance: none; + -webkit-appearance: none; + appearance: none; + opacity: 0; + } +} + +.css_not_available { + opacity: 0.6; +} + +option.css_not_available { + opacity: 1; + color: #ccc; +} diff --git a/addons/sale_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.xml b/addons/sale_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.xml new file mode 100644 index 00000000000..cd2715fcd09 --- /dev/null +++ b/addons/sale_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + +
    +
  • +
    + + +
    +
  • +
+
+ +
    +
  • +
    + + +
    +
  • +
+
+ +
    +
  • + +
  • +
+
+
diff --git a/addons/sale_product_configurator/static/src/js/sale_product_field.js b/addons/sale_product_configurator/static/src/js/sale_product_field.js index 4e96d2b1dd5..74560f088f8 100644 --- a/addons/sale_product_configurator/static/src/js/sale_product_field.js +++ b/addons/sale_product_configurator/static/src/js/sale_product_field.js @@ -1,23 +1,18 @@ -/** @odoo-module **/ +/** @odoo-module */ import { patch } from "@web/core/utils/patch"; import { useService } from "@web/core/utils/hooks"; import { SaleOrderLineProductField } from '@sale/js/sale_product_field'; -import { OptionalProductsModal } from "@sale_product_configurator/js/product_configurator_modal"; -import { - selectOrCreateProduct, - getSelectedVariantValues, - getNoVariantAttributeValues, -} from "sale.VariantMixin"; - +import { serializeDateTime } from "@web/core/l10n/dates"; +import { ProductConfiguratorDialog } from "./product_configurator_dialog/product_configurator_dialog"; patch(SaleOrderLineProductField.prototype, 'sale_product_configurator', { setup() { this._super(...arguments); - this.rpc = useService("rpc"); - this.ui = useService("ui"); + this.dialog = useService("dialog"); + this.orm = useService("orm"); }, async _onProductTemplateUpdate() { @@ -36,17 +31,17 @@ patch(SaleOrderLineProductField.prototype, 'sale_product_configurator', { product_id: [result.product_id, result.product_name], }); if (result.has_optional_products) { - this._openProductConfigurator('options'); + this._openProductConfigurator(); } else { this._onProductUpdate(); } } } else { if (!result.mode || result.mode === 'configurator') { - this._openProductConfigurator('add'); + this._openProductConfigurator(); } else { // only triggered when sale_product_matrix is installed. - this._openGridConfigurator(result.mode); + this._openGridConfigurator(); } } }, @@ -54,7 +49,7 @@ patch(SaleOrderLineProductField.prototype, 'sale_product_configurator', { _editProductConfiguration() { this._super(...arguments); if (this.props.record.data.is_configurable_product) { - this._openProductConfigurator('edit'); + this._openProductConfigurator(true); } }, @@ -62,205 +57,60 @@ patch(SaleOrderLineProductField.prototype, 'sale_product_configurator', { return this._super(...arguments) || this.props.record.data.is_configurable_product; }, - async _openProductConfigurator(mode) { + async _openProductConfigurator(edit=false) { const saleOrderRecord = this.props.record.model.root; - const pricelistId = saleOrderRecord.data.pricelist_id ? saleOrderRecord.data.pricelist_id[0] : false; - const productTemplateId = this.props.record.data.product_template_id[0]; - const $modal = $( - await this.rpc( - "/sale_product_configurator/configure", - { - product_template_id: productTemplateId, - quantity: this.props.record.data.product_uom_qty || 1, - pricelist_id: pricelistId, - product_template_attribute_value_ids: this.props.record.data.product_template_attribute_value_ids.records.map( - record => record.data.id - ), - product_no_variant_attribute_value_ids: this.props.record.data.product_no_variant_attribute_value_ids.records.map( - record => record.data.id - ), - context: this.context, - }, - ) - ); - const productSelector = `input[type="hidden"][name="product_id"], input[type="radio"][name="product_id"]:checked`; - // TODO VFE drop this selectOrCreate and make it so that - // get_single_product_variant returns first variant as well. - // and use specified product on edition mode. - const productId = await selectOrCreateProduct.call( - this, - $modal, - parseInt($modal.find(productSelector).first().val(), 10), - productTemplateId, - false - ); - $modal.find(productSelector).val(productId); - const variantValues = getSelectedVariantValues($modal); - const noVariantAttributeValues = getNoVariantAttributeValues($modal); - const customAttributeValues = this.props.record.data.product_custom_attribute_value_ids.records.map( - record => { - // NOTE: this dumb formatting is necessary to avoid - // modifying the shared code between frontend & backend for now. - return { - custom_value: record.data.custom_value, - custom_product_template_attribute_value_id: { - res_id: record.data.custom_product_template_attribute_value_id[0], - }, - }; - } - ); - this.rootProduct = { - product_id: productId, - product_template_id: productTemplateId, - quantity: parseFloat($modal.find('input[name="add_qty"]').val() || 1), - variant_values: variantValues, - product_custom_attribute_values: customAttributeValues, - no_variant_attribute_values: noVariantAttributeValues, - }; - const optionalProductsModal = new OptionalProductsModal(null, { - rootProduct: this.rootProduct, - pricelistId: pricelistId, - okButtonText: this.env._t("Confirm"), - cancelButtonText: this.env._t("Back"), - title: this.env._t("Configure"), - context: this.context, - mode: mode, - }); - let modalEl; - optionalProductsModal.opened(() => { - modalEl = optionalProductsModal.el; - this.ui.activateElement(modalEl); - }); - optionalProductsModal.on("closed", null, async () => { - // Wait for the event that caused the close to bubble - await new Promise(resolve => setTimeout(resolve, 0)); - this.ui.deactivateElement(modalEl); - }); - optionalProductsModal.open(); - let confirmed = false; - optionalProductsModal.on("confirm", null, async () => { - confirmed = true; - const [ - mainProduct, - ...optionalProducts - ] = await optionalProductsModal.getAndCreateSelectedProducts(); - - await this.props.record.update(await this._convertConfiguratorDataToUpdateData(mainProduct)); - this._onProductUpdate(); - const optionalProductLinesCreationContext = this._convertConfiguratorDataToLinesCreationContext(optionalProducts); - for (let optionalProductLineCreationContext of optionalProductLinesCreationContext) { - const line = await saleOrderRecord.data.order_line.addNew({ - position: 'bottom', - context: optionalProductLineCreationContext, - mode: 'readonly', // whatever but not edit ! - }); - // FIXME: update sets the field dirty otherwise on the next edit and click out it gets deleted - line.update({ sequence: line.data.sequence }); - } - saleOrderRecord.data.order_line.unselectRecord(); - }); - optionalProductsModal.on("closed", null, () => { - if (confirmed) { - return; - } - if (mode != 'edit') { - this.props.record.update({ - product_template_id: false, - product_id: false, - product_uom_qty: 1.0, - // TODO reset custom/novariant values (and remove onchange logic?) - }); - } - }); - }, - - async _convertConfiguratorDataToUpdateData(mainProduct) { - const nameGet = await this.orm.nameGet( - 'product.product', - [mainProduct.product_id], - { context: this.context } - ); - let result = { - product_id: nameGet[0], - product_uom_qty: mainProduct.quantity, - }; - var customAttributeValues = mainProduct.product_custom_attribute_values; - var customValuesCommands = [{ operation: "DELETE_ALL" }]; - if (customAttributeValues && customAttributeValues.length !== 0) { - customAttributeValues.forEach((customValue) => { - customValuesCommands.push({ - operation: "CREATE", - context: [ - { - default_custom_product_template_attribute_value_id: - customValue.custom_product_template_attribute_value_id, - default_custom_value: customValue.custom_value, - }, - ], - }); - }); - } - - result.product_custom_attribute_value_ids = { - operation: "MULTI", - commands: customValuesCommands, - }; - - var noVariantAttributeValues = mainProduct.no_variant_attribute_values; - var noVariantCommands = [{ operation: "DELETE_ALL" }]; - if (noVariantAttributeValues && noVariantAttributeValues.length !== 0) { - var resIds = noVariantAttributeValues.map((noVariantValue) => { - return { id: parseInt(noVariantValue.value) }; - }); - - noVariantCommands.push({ - operation: "ADD_M2M", - ids: resIds, - }); - } - - result.product_no_variant_attribute_value_ids = { - operation: "MULTI", - commands: noVariantCommands, - }; - - return result; - }, - - /** - * Will map the optional producs data to sale.order.line - * creation contexts. - * - * @param {Array} optionalProductsData The optional products data given by the configurator - * - * @private - */ - _convertConfiguratorDataToLinesCreationContext: function (optionalProductsData) { - return optionalProductsData.map(productData => { - return { - default_product_id: productData.product_id, - default_product_template_id: productData.product_template_id, - default_product_uom_qty: productData.quantity, - default_product_no_variant_attribute_value_ids: productData.no_variant_attribute_values.map( - noVariantAttributeData => { - return [4, parseInt(noVariantAttributeData.value)]; + /** + * `product_custom_attribute_value_ids` records are not loaded in the view bc sub templates + * are not loaded in list views. Therefore, we fetch them from the server if the record is + * saved. Else we use the value stored on the line. + */ + const customAttributeValues = + this.props.record.data.product_custom_attribute_value_ids.records[0]?.isNew ? + this.props.record.data.product_custom_attribute_value_ids.records.map( + record => record.data + ) : + await this.orm.read( + 'product.attribute.custom.value', + this.props.record.data.product_custom_attribute_value_ids.currentIds, + ["custom_product_template_attribute_value_id", "custom_value"] + ); + this.dialog.add(ProductConfiguratorDialog, { + productTemplateId: this.props.record.data.product_template_id[0], + ptavIds: this.props.record.data.product_template_attribute_value_ids.records.map( + record => record.data.id + ).concat(this.props.record.data.product_no_variant_attribute_value_ids.records.map( + record => record.data.id + )), + customAttributeValues: customAttributeValues.map( + data => { + return { + ptavId: data.custom_product_template_attribute_value_id[0], + value: data.custom_value, } - ), - default_product_custom_attribute_value_ids: productData.product_custom_attribute_values.map( - customAttributeData => { - return [ - 0, - 0, - { - custom_product_template_attribute_value_id: - customAttributeData.custom_product_template_attribute_value_id, - custom_value: customAttributeData.custom_value, - }, - ]; - } - ) - }; + } + ), + quantity: this.props.record.data.product_uom_qty, + productUOMId: this.props.record.data.product_uom[0], + companyId: saleOrderRecord.data.company_id[0], + pricelistId: saleOrderRecord.data.pricelist_id[0], + currencyId: this.props.record.data.currency_id[0], + soDate: serializeDateTime(saleOrderRecord.data.date_order), + edit: edit, + save: async (mainProduct, optionalProducts) => { + await this.props.record.update(mainProduct); + this._onProductUpdate(); + for (const optionalProduct of optionalProducts) { + const line = await saleOrderRecord.data.order_line.addNew({ + position: 'bottom', + }); + line.update(optionalProduct); + } + saleOrderRecord.data.order_line.unselectRecord(); + }, + discard: () => { + saleOrderRecord.data.order_line.removeRecord(this.props.record); + }, }); }, }); diff --git a/addons/sale_product_configurator/tests/common.py b/addons/sale_product_configurator/tests/common.py index 2c85522a83f..e6d14e46c66 100644 --- a/addons/sale_product_configurator/tests/common.py +++ b/addons/sale_product_configurator/tests/common.py @@ -27,16 +27,19 @@ class TestProductConfiguratorCommon(TransactionCase): }) product_attribute_2 = cls.env['product.attribute'].create({ 'name': 'Color', + 'display_type': 'color', 'sequence': 20, }) product_attribute_value_3 = cls.env['product.attribute.value'].create({ 'name': 'White', 'attribute_id': product_attribute_2.id, + 'html_color': '#FFFFFF', 'sequence': 1, }) product_attribute_value_4 = cls.env['product.attribute.value'].create({ 'name': 'Black', 'attribute_id': product_attribute_2.id, + 'html_color': '#000000', 'sequence': 2, }) diff --git a/addons/sale_product_matrix/static/src/js/sale_product_field.js b/addons/sale_product_matrix/static/src/js/sale_product_field.js index 5485cd05a04..008497c46bd 100644 --- a/addons/sale_product_matrix/static/src/js/sale_product_field.js +++ b/addons/sale_product_matrix/static/src/js/sale_product_field.js @@ -14,7 +14,7 @@ patch(SaleOrderLineProductField.prototype, 'sale_product_matrix', { this.dialog = useService("dialog"); }, - async _openGridConfigurator(mode) { + async _openGridConfigurator(edit=false) { const saleOrderRecord = this.props.record.model.root; // fetch matrix information from server; @@ -23,7 +23,7 @@ patch(SaleOrderLineProductField.prototype, 'sale_product_matrix', { }); let updatedLineAttributes = []; - if (mode === 'edit') { + if (edit) { // provide attributes of edited line to automatically focus on matching cell in the matrix for (let ptnvav of this.props.record.data.product_no_variant_attribute_value_ids.records) { updatedLineAttributes.push(ptnvav.data.id); @@ -40,15 +40,15 @@ patch(SaleOrderLineProductField.prototype, 'sale_product_matrix', { updatedLineAttributes, ); - if (mode !== 'edit') { + if (!edit) { // remove new line used to open the matrix saleOrderRecord.data.order_line.removeRecord(this.props.record); } }, - async _openProductConfigurator(mode) { - if (mode === 'edit' && this.props.record.data.product_add_mode == 'matrix') { - this._openGridConfigurator('edit'); + async _openProductConfigurator(edit=false) { + if (edit && this.props.record.data.product_add_mode == 'matrix') { + this._openGridConfigurator(true); } else { this._super(...arguments); } diff --git a/addons/test_sale_product_configurators/static/tests/tours/event_sale_with_product_configurator_ui.js b/addons/test_sale_product_configurators/static/tests/tours/event_sale_with_product_configurator_ui.js index f0a00e33d2a..cedbdacab9a 100644 --- a/addons/test_sale_product_configurators/static/tests/tours/event_sale_with_product_configurator_ui.js +++ b/addons/test_sale_product_configurators/static/tests/tours/event_sale_with_product_configurator_ui.js @@ -25,11 +25,9 @@ registry.category("web_tour.tours").add('event_sale_with_product_configurator_to }, { trigger: 'ul.ui-autocomplete a:contains("Registration Event (TEST variants)")', }, { - trigger: '.js_product:has(strong:contains(Memorabilia)) .js_add', - extra_trigger: '.oe_advanced_configurator_modal', + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Memorabilia")) button:has(i.fa-shopping-cart)', }, { - trigger: 'button span:contains(Confirm)', - extra_trigger: '.oe_advanced_configurator_modal', // to confirm the first wizard + trigger: 'button:contains(Confirm)', }, { trigger: '.o_input_dropdown input', extra_trigger: '.o_technical_modal', // to be in the event wizard @@ -56,16 +54,14 @@ registry.category("web_tour.tours").add('event_sale_with_product_configurator_to }, { trigger: 'ul.ui-autocomplete a:contains("Registration Event (TEST variants)")', }, { - trigger: '.radio_input_value span:contains(Adult)', + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Registration Event (TEST variants)")) label:contains("Adult")', }, { - trigger: '.js_quantity', - extra_trigger: '.oe_advanced_configurator_modal', + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Registration Event (TEST variants)"))>td:nth-child(3) input', run: 'text 5', }, { - trigger: '.js_price_total span:contains("150.00")', // to be sure the correct variant is set + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(2)>td>span:contains("150.00")', // to be sure the correct variant is set }, { - trigger: 'button span:contains(Confirm)', - extra_trigger: '.oe_advanced_configurator_modal', + trigger: 'button:contains(Confirm)', }, { trigger: '.o_input_dropdown input', extra_trigger: '.o_technical_modal', @@ -92,12 +88,11 @@ registry.category("web_tour.tours").add('event_sale_with_product_configurator_to }, { trigger: 'ul.ui-autocomplete a:contains("Registration Event (TEST variants)")', }, { - trigger: '.radio_input_value span:contains(VIP)', + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Registration Event (TEST variants)")) label:contains("VIP")', }, { - trigger: '.js_price_total span:contains("60.00")', // to be sure the correct variant is set + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(2)>td>span:contains("60.00")', // to be sure the correct variant is set }, { - trigger: 'button span:contains(Confirm)', - extra_trigger: '.oe_advanced_configurator_modal', + trigger: 'button:contains(Confirm)', }, { trigger: '.o_input_dropdown input', extra_trigger: '.o_technical_modal', diff --git a/addons/test_sale_product_configurators/static/tests/tours/product_configurator_advanced_ui.js b/addons/test_sale_product_configurators/static/tests/tours/product_configurator_advanced_ui.js index 52f37342d51..709db18b355 100644 --- a/addons/test_sale_product_configurators/static/tests/tours/product_configurator_advanced_ui.js +++ b/addons/test_sale_product_configurators/static/tests/tours/product_configurator_advanced_ui.js @@ -28,72 +28,69 @@ registry.category("web_tour.tours").add('sale_product_configurator_advanced_tour }, { trigger: 'ul.ui-autocomplete a:contains("Customizable Desk (TEST)")', }, { - trigger: 'span:contains("Custom")', - extra_trigger: '.oe_advanced_configurator_modal', + trigger: 'main.modal-body>table:nth-child(1)>tbody span:contains("Custom")' }, { - trigger: 'ul.js_add_cart_variants li[data-attribute_id]:nth-child(1) .variant_custom_value', + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1)>td:nth-child(2)>input', run: 'text Custom 1' }, { - trigger: 'ul.js_add_cart_variants li[data-attribute_id]:nth-child(3) div:contains("PAV9") input', + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1)>td:nth-child(2)>ul:nth-child(8) span:contains("PAV9")', }, { - trigger: 'ul.js_add_cart_variants li[data-attribute_id]:nth-child(3) .variant_custom_value', + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1)>td:nth-child(2)>ul:nth-child(8) ~ input', run: 'text Custom 2' }, { - trigger: 'ul.js_add_cart_variants li[data-attribute_id]:nth-child(4) div:contains("PAV5") input', + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1)>td:nth-child(2)>ul:nth-child(11) span:contains("PAV5")', }, { - trigger: 'ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) select ', + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1)>td:nth-child(2)>select:nth-child(15)', run: function (){ - let 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'); + let inputValue = $('main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1)>td:nth-child(2)>select:nth-child(15) option:contains("PAV9")').val(); + $('main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1)>td:nth-child(2)>select:nth-child(15)').val(inputValue); + $('main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1)>td:nth-child(2)>select:nth-child(15)')[0].dispatchEvent(new Event("change")); } }, { - trigger: 'ul.js_add_cart_variants li[data-attribute_id]:nth-child(6) .variant_custom_value', + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1)>td:nth-child(2)>select:nth-child(15) ~ input', run: 'text Custom 3' }, { - trigger: '.main_product strong:contains("Custom, White, PAV9, PAV5, PAV1")', - run: function () {} //check + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1) strong:contains("Custom, White, PAV9, PAV5, PAV1")', + isCheck: true, }, { - trigger: '.js_product:eq(1) div:contains("Conference Chair (TEST) (Steel)")', + trigger: 'main.modal-body>table:nth-child(2)>tbody>tr:nth-child(1)>td:nth-child(2):contains("Conference Chair (TEST) (Steel)")', run: function () { - optionVariantImage = $('.oe_advanced_configurator_modal .js_product:eq(1) img.variant_image').attr('src'); + optionVariantImage = $('main.modal-body>table:nth-child(2)>tbody>tr:nth-child(1)>td:nth-child(1)>img').attr('src'); } }, { - trigger: '.js_product:eq(1) input[data-value_name="Aluminium"]', + trigger: 'main.modal-body>table:nth-child(2)>tbody>tr:nth-child(1) label:contains("Aluminium")', }, { - trigger: '.js_product:eq(1) div:contains("Conference Chair (TEST) (Aluminium)")', + trigger: 'main.modal-body>table:nth-child(2)>tbody>tr:nth-child(1)>td:nth-child(2):contains("Conference Chair (TEST) (Aluminium)")', run: function () { - let newVariantImage = $('.oe_advanced_configurator_modal .js_product:eq(1) img.variant_image').attr('src'); + let newVariantImage = $('main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1)>td:nth-child(1)>img').attr('src'); if (newVariantImage !== optionVariantImage) { - $('

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

').text('image variant option src changed').insertAfter('main.modal-body>table:nth-child(2)>tbody>tr:nth-child(1)>td:nth-child(2)>div>strong'); } - } }, { - trigger: '.js_product:eq(1) input[data-value_name="Steel"]', - extra_trigger: '.js_product:eq(1) div:contains("image variant option src changed")', + trigger: 'main.modal-body>table:nth-child(2)>tbody>tr:nth-child(1)>td:nth-child(2)>div:contains("image variant option src changed")', }, { - trigger: 'button span:contains(Confirm)', + trigger: 'button:contains(Confirm)', }, { trigger: 'td.o_data_cell:contains("Customizable Desk (TEST) (Custom, White, PAV9, PAV5, PAV1)")', - run: function (){} //check + isCheck: true, }, { trigger: 'td.o_data_cell:contains("Legs: Custom: Custom 1")', - run: function (){} //check + isCheck: true, }, { trigger: 'td.o_data_cell:contains("PA1: PAV9: Custom 2")', - run: function (){} //check + isCheck: true, }, { trigger: 'td.o_data_cell:contains("PA4: PAV9: Custom 3")', - run: function (){} //check + isCheck: true, }, { trigger: 'td.o_data_cell:contains("PA5: PAV1")', - run: function (){} //check + isCheck: true, }, { trigger: 'td.o_data_cell:contains("PA7: PAV1")', - run: function (){} //check + isCheck: true, }, { trigger: 'td.o_data_cell:contains("PA8: PAV1")', - run: function (){} //check + isCheck: true, }, ...stepUtils.discardForm() ]}); diff --git a/addons/test_sale_product_configurators/static/tests/tours/product_configurator_edition_ui.js b/addons/test_sale_product_configurators/static/tests/tours/product_configurator_edition_ui.js index 5e7d36790e8..540c6da88a0 100644 --- a/addons/test_sale_product_configurators/static/tests/tours/product_configurator_edition_ui.js +++ b/addons/test_sale_product_configurators/static/tests/tours/product_configurator_edition_ui.js @@ -19,120 +19,77 @@ registry.category("web_tour.tours").add('sale_product_configurator_edition_tour' }, { trigger: 'ul.ui-autocomplete a:contains("Customizable Desk (TEST)")', }, { - trigger: '.main_product span:contains("Steel")', - run: function () { - $('input.product_id').change(function () { - $('.show .modal-footer .btn-primary').attr('request_count', 1); - }); - } + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Customizable Desk")) label:contains("Aluminium")', }, { - trigger: '.main_product span:contains("Aluminium")', - extra_trigger: '.main_product .form-check:has(input[data-value_name="Steel"]:checked)', + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Customizable Desk (TEST) (Aluminium, White)"))', + isCheck: true, }, { - trigger: '.main_product .product_display_name:contains("Customizable Desk (TEST) (Aluminium, White)")', -}, { - trigger: '.btn-primary[request_count="1"]', - extra_trigger: '.show .modal-footer', - run: function (){} // used to sync with "get_combination_info" completion -}, { - trigger: '.btn-primary:not(.disabled)', - extra_trigger: '.show .modal-footer', -}, { - trigger: 'button span:contains(Confirm)', + trigger: 'button:contains(Confirm)', }, { trigger: 'td.o_data_cell:contains("Customizable Desk (TEST) (Aluminium, White)")', extra_trigger: 'div[name="order_line"]', - run: function (){} // check added product + isCheck: true // check added product }, { trigger: 'div[name="product_template_id"]', }, { trigger: '.fa-pencil', }, { - trigger: '.main_product li.js_attribute_value:contains("Aluminium") input:checked', - run: function (){} // check updated legs + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Customizable Desk"))>td:nth-child(2)>ul:nth-child(3)>li:nth-child(2)>div label:contains("Aluminium") ~ input:checked', + isCheck: true, // check updated legs }, { - trigger: 'span.oe_currency_value:contains("800")', - run: function (){} // check updated price + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Customizable Desk"))>td:nth-child(4) span:contains("800.40")', + isCheck: true, // check updated price }, { - trigger: '.main_product span:contains("Steel")', - run: function () { - $('input.product_id').change(function () { - if ($('.o_sale_product_configurator_edit').attr('request_count')) { - $('.o_sale_product_configurator_edit').attr('request_count', - parseInt($('.o_sale_product_configurator_edit').attr('request_count')) + 1); - } else { - $('.o_sale_product_configurator_edit').attr('request_count', 1); - } - }); - } + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Customizable Desk")) label:contains("Custom") ~ input', }, { - trigger: '.main_product span:contains("Custom")', - run: function () { - // FIXME awa: since jquery3 update it doesn't "click" - // on the element without this run (and 'run: "click"' - // doesn't work either) - $('.main_product span:contains("Custom")').click(); - } -}, { - trigger: '.main_product .variant_custom_value', + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Customizable Desk"))>td:nth-child(2)>input', run: 'text nice custom value' }, { - trigger: 'input[data-value_name="Black"]', + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Customizable Desk")) label[style="background-color:#000000"] input', }, { - trigger: '.product_display_name:contains("Customizable Desk (TEST) (Custom, Black)")', - run: function (){} // used to sync with "get_combination_info" completion + trigger: '.o_sale_product_configurator_name:contains("Customizable Desk (TEST) (Custom, Black)")', + isCheck: true, // used to sync with server }, { - trigger: '.o_sale_product_configurator_edit', + trigger: 'button:contains(Confirm)', }, { trigger: 'td.o_data_cell:contains("Customizable Desk (TEST) (Custom, Black)")', extra_trigger: 'div[name="order_line"]', - run: function (){} // check updated product + isCheck: true, // check updated product }, { trigger: 'td.o_data_cell:contains("Custom: nice custom value")', extra_trigger: 'div[name="order_line"]', - run: function (){} // check custom value + isCheck: true, // check custom value }, { trigger: 'div[name="product_template_id"]', }, { trigger: '.fa-pencil', }, { - trigger: '.main_product .variant_custom_value', + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Customizable Desk"))>td:nth-child(2)>input', run: 'text another nice custom value' }, { - trigger: '.o_sale_product_configurator_edit', + trigger: 'button:contains(Confirm)', }, { trigger: 'td.o_data_cell:contains("Custom: another nice custom value")', extra_trigger: 'div[name="order_line"]', - run: function (){} // check custom value + isCheck: true, // check custom value }, { trigger: 'div[name="product_template_id"]', }, { trigger: '.fa-pencil', }, { - trigger: '.main_product span:contains("Steel")', - run: function () { - $('input.product_id').change(function () { - $('.o_sale_product_configurator_edit').attr('request_count', 1); - }); - } + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Customizable Desk")) label:contains("Steel") ~ input', +}, { + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Customizable Desk (TEST) (Steel, Black)"))', + isCheck: true, }, { - trigger: '.main_product span:contains("Steel")', - run: function () { - // FIXME awa: since jquery3 update it doesn't "click" - // on the element without this run (and 'run: "click"' - // doesn't work either) - $('.main_product span:contains("Steel")').click(); - } + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Customizable Desk"))>td:nth-child(3)>div>button:has(i.fa-plus)', }, { - trigger: '.o_sale_product_configurator_edit[request_count="1"]', - run: function (){} // used to sync with "get_combination_info" completion + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(2)>td>span:contains("1,500.00")', }, { - trigger: '.main_product button.js_add_cart_json:has(.fa-plus)', -}, { - trigger: '.o_sale_product_configurator_edit', + trigger: 'button:contains(Confirm)', }, { trigger: 'td.o_data_cell:contains("2.00")', - run: function (){} // check quantity + isCheck: true, // check quantity }, { trigger: 'div[name="product_template_id"]', run: function () { @@ -144,7 +101,7 @@ registry.category("web_tour.tours").add('sale_product_configurator_edition_tour' }, { trigger: 'td.o_data_cell:contains("tour success")', extra_trigger: 'div[name="order_line"]', - run: function() {}, + isCheck: true, }, ...stepUtils.discardForm(), ]}); diff --git a/addons/test_sale_product_configurators/static/tests/tours/product_configurator_optional_products_ui.js b/addons/test_sale_product_configurators/static/tests/tours/product_configurator_optional_products_ui.js index b123c5bcf87..8e07a040a43 100644 --- a/addons/test_sale_product_configurators/static/tests/tours/product_configurator_optional_products_ui.js +++ b/addons/test_sale_product_configurators/static/tests/tours/product_configurator_optional_products_ui.js @@ -19,45 +19,30 @@ registry.category("web_tour.tours").add('sale_product_configurator_optional_prod }, { trigger: 'ul.ui-autocomplete a:contains("Customizable Desk (TEST)")', }, { - trigger: 'tr:has(.td-product_name:contains("Office Chair Black")) .js_add', + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Office Chair Black")) button:has(i.fa-shopping-cart)', }, { - trigger: 'tr:has(.td-product_name:contains("Customizable Desk")) .fa-plus' + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Customizable Desk")) button:has(i.fa-plus)' }, { - trigger: 'tr:has(.td-product_name:contains("Chair floor protection")) .js_add', + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Chair floor protection")) button:has(i.fa-shopping-cart)', }, { - content: 'Is below its parent 1', - trigger: 'tr:has(.td-product_name:contains("Office Chair Black")) + tr:has(.td-product_name:contains("Chair floor protection"))' + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Conference Chair")) button:has(i.fa-shopping-cart)', }, { - trigger: 'tr:has(.td-product_name:contains("Conference Chair")) .js_add', + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Conference Chair")) a:has(i.fa-trash-o)', }, { - trigger: 'tr:has(.td-product_name:contains("Conference Chair")) .fa-minus' + trigger: 'tr:has(.o_sale_product_configurator_name:contains("Conference Chair")) button:has(i.fa-shopping-cart)', }, { - trigger: 'tr:has(.td-product_name:contains("Chair floor protection")) .js_add', -}, { - content: 'Is below its parent 2', - trigger: 'tr:has(.td-product_name:contains("Conference Chair")) + tr:has(.td-product_name:contains("Chair floor protection"))' -}, { - trigger: 'button span:contains(Confirm)', - extra_trigger: '.oe_advanced_configurator_modal', + trigger: 'button:contains(Confirm)', }, { trigger: 'tr:has(td.o_data_cell:contains("Customizable Desk")) td.o_data_cell:contains("2.0")', - extra_trigger: 'div[name="order_line"]', - run: function () {}, // check added product + isCheck: true, }, { trigger: 'tr:has(td.o_data_cell:contains("Office Chair Black")) td.o_data_cell:contains("1.0")', - extra_trigger: 'div[name="order_line"]', - run: function () {}, // check added product + isCheck: true, }, { trigger: 'tr:has(td.o_data_cell:contains("Conference Chair")) td.o_data_cell:contains("1.0")', - extra_trigger: 'div[name="order_line"]', - run: function () {}, // check added product + isCheck: true, }, { - trigger: 'tr:has(td.o_data_cell:contains("Chair floor protection")):nth(0) td.o_data_cell:contains("1.0")', - extra_trigger: 'div[name="order_line"]', - run: function () {}, // check added product -}, { - trigger: 'tr:has(td.o_data_cell:contains("Chair floor protection")):nth(1) td.o_data_cell:contains("1.0")', - extra_trigger: 'div[name="order_line"]', - run: function () {}, // check added product + trigger: 'tr:has(td.o_data_cell:contains("Chair floor protection")) td.o_data_cell:contains("1.0")', + isCheck: true, }, ...stepUtils.discardForm() ]}); diff --git a/addons/test_sale_product_configurators/static/tests/tours/product_configurator_pricelist_ui.js b/addons/test_sale_product_configurators/static/tests/tours/product_configurator_pricelist_ui.js index 9f03a113988..acab69140ba 100644 --- a/addons/test_sale_product_configurators/static/tests/tours/product_configurator_pricelist_ui.js +++ b/addons/test_sale_product_configurators/static/tests/tours/product_configurator_pricelist_ui.js @@ -44,33 +44,31 @@ stepUtils.showAppsMenuItem(), trigger: 'ul.ui-autocomplete a:contains("Customizable Desk (TEST)")', }, { content: "check price is correct (USD)", - trigger: 'span.oe_currency_value:contains("750.00")', - run: function () {} // check price + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1)>td:nth-child(4) span:contains("750.00")', + isCheck: true, }, { content: "add one more", - trigger: 'button.js_add_cart_json:has(i.fa-plus)', + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1)>td:nth-child(3)>div>button:has(i.fa-plus)', }, { content: "check price for 2", - trigger: 'span.oe_currency_value:contains("600.00")', - run: function () {} // check price (pricelist has discount for 2) -}, { - content: "check we are on the add modal", - trigger: '.td-product_name:contains("Customizable Desk (TEST) (Steel, White)")', - extra_trigger: '.oe_advanced_configurator_modal', + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1)>td:nth-child(4) span:contains("600.00")', + isCheck: true, }, { content: "add conference chair", - trigger: '.js_product:has(strong:contains(Conference Chair)) .js_add', - extra_trigger: '.oe_advanced_configurator_modal .js_product:has(strong:contains(Conference Chair))', + trigger: 'main.modal-body>table:nth-child(2)>tbody>tr:nth-child(1)>td:nth-child(3)>button', +}, { + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(2)>td:nth-child(3)>div>button:has(i.fa-plus)', }, { content: "add chair floor protection", - trigger: '.js_product:has(strong:contains(Chair floor protection)) .js_add', - extra_trigger: '.oe_advanced_configurator_modal .js_product:has(strong:contains(Chair floor protection))', + trigger: 'main.modal-body>table:nth-child(2)>tbody>tr:nth-child(1)>td:nth-child(3)>button', +}, { + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(3)>td:nth-child(3)>div>button:has(i.fa-plus)', }, { content: "verify configurator final price", // tax excluded - trigger: '.o_total_row .oe_currency_value:contains("1,257.00")', + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(4)>td>span:contains("1,257.00")', }, { content: "add to SO", - trigger: 'button span:contains(Confirm)', + trigger: 'button:contains(Confirm)', }, { content: "verify SO final price excluded", trigger: 'span[name="Untaxed Amount"]:contains("1,257.00")', diff --git a/addons/test_sale_product_configurators/static/tests/tours/product_configurator_single_custom_attribute_ui.js b/addons/test_sale_product_configurators/static/tests/tours/product_configurator_single_custom_attribute_ui.js index 34752bc377c..9ad5785b1f2 100644 --- a/addons/test_sale_product_configurators/static/tests/tours/product_configurator_single_custom_attribute_ui.js +++ b/addons/test_sale_product_configurators/static/tests/tours/product_configurator_single_custom_attribute_ui.js @@ -19,23 +19,10 @@ registry.category("web_tour.tours").add('sale_product_configurator_single_custom }, { trigger: 'ul.ui-autocomplete a:contains("Customizable Desk (TEST)")', }, { - trigger: '.oe_advanced_configurator_modal span:contains("Aluminium")', - run: function () { - // used to check that the radio is NOT rendered - 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: '.oe_advanced_configurator_modal.tour_success', - run: function () { - //check - } -}, { - trigger: '.oe_advanced_configurator_modal .variant_custom_value', + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1)>td:nth-child(2)>input:nth-child(7)', run: 'text great single custom value' }, { - trigger: 'button span:contains(Confirm)', + trigger: 'button:contains(Confirm)', }, { trigger: 'td.o_data_cell:contains("single product attribute value: great single custom value")', extra_trigger: 'div[name="order_line"]', @@ -45,24 +32,18 @@ registry.category("web_tour.tours").add('sale_product_configurator_single_custom }, { trigger: '.fa-pencil', }, { - trigger: '.main_product .variant_custom_value', + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1)>td:nth-child(2)>input:nth-child(7)', run: function () { // check custom value initialized - if ($('.main_product .variant_custom_value').val() === "great single custom value") { - $('.main_product').addClass('tour_success_2'); + if ($('main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1)>td:nth-child(2)>input:nth-child(7)').val() === "great single custom value") { + $('main').addClass('tour_success_2'); } } }, { - trigger: '.main_product.tour_success_2', - run: function () { - //check - } + trigger: 'main.tour_success_2', + isCheck: true, }, { - trigger: '.main_product', - run: function () { - window.location.href = window.location.origin + '/web'; - } -}, { - trigger: '.o_navbar', - run: function() {}, // Check the home page is loaded -}]}); + trigger: 'button:contains(Back)', +}, + ...stepUtils.discardForm() +]}); diff --git a/addons/test_sale_product_configurators/static/tests/tours/product_configurator_ui.js b/addons/test_sale_product_configurators/static/tests/tours/product_configurator_ui.js index 432f89bffbd..2d90f5e7b4b 100644 --- a/addons/test_sale_product_configurators/static/tests/tours/product_configurator_ui.js +++ b/addons/test_sale_product_configurators/static/tests/tours/product_configurator_ui.js @@ -22,46 +22,46 @@ registry.category("web_tour.tours").add('sale_product_configurator_tour', { }, { trigger: 'ul.ui-autocomplete a:contains("Customizable Desk (TEST)")', }, { - trigger: '.main_product span:contains("Steel")', - run: function () {}, + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1) label:contains("Steel")', + isCheck: true, }, { - trigger: '.main_product span:contains("Aluminium")', + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1) label:contains("Aluminium")', }, { - trigger: 'span.oe_currency_value:contains("800.40")', - run: function (){} // check updated price + trigger: 'main.modal-body>table:nth-child(1)>tbody>tr:nth-child(1)>td:nth-child(4)>span:contains("800.40")', + isCheck: true, // check updated price }, { - trigger: 'input[data-value_name="Black"]' + trigger: 'label[style="background-color:#000000"] input' }, { - trigger: '.btn-primary.disabled span:contains("Confirm")', - extra_trigger: '.show .modal-footer' // check confirm is disable and try to do it anyway + trigger: '.btn-primary:disabled:contains("Confirm")', + isCheck: true, // check confirm button is disabled }, { - trigger: 'input[data-value_name="White"]' + trigger: 'label[style="background-color:#FFFFFF"] input' }, { - trigger: '.btn-primary:not(.disabled) span:contains("Confirm")', - extra_trigger: '.show .modal-footer', - run: function (){} // check confirm is available + trigger: '.btn-primary:not(:disabled):contains("Confirm")', + extra_trigger: '.modal-footer', + isCheck: true, // check confirm is available }, { trigger: 'span:contains("Aluminium"):eq(1)', }, { - trigger: '.js_product:contains(Conference Chair) .js_add', + trigger: 'main.modal-body>table:nth-child(2)>tbody>tr:nth-child(1)>td:nth-child(3)>button', }, { - trigger: '.js_product:contains(Chair floor protection) .js_add', + trigger: 'main.modal-body>table:nth-child(2)>tbody>tr:nth-child(1)>td:nth-child(3)>button', }, { - trigger: 'button span:contains(Confirm)', + trigger: 'button:contains(Confirm)', id: 'quotation_product_selected', }, // check that 3 products were added to the SO { trigger: 'td.o_data_cell:contains("Customizable Desk (TEST) (Aluminium, White)")', - run: function (){} + isCheck: true, }, { trigger: 'td.o_data_cell:contains("Conference Chair (TEST) (Aluminium)")', - run: function (){} + isCheck: true, }, { trigger: 'td.o_data_cell:contains("Chair floor protection")', - run: function (){} + isCheck: true, }, { trigger: 'span[name=amount_total]:contains("960.60")', - run: function (){} + isCheck: true, }, ...stepUtils.discardForm() ]}); diff --git a/addons/test_sale_product_configurators/tests/test_sale_product_configurator.py b/addons/test_sale_product_configurators/tests/test_sale_product_configurator.py index 9d1d26259f3..ebc69dcda65 100644 --- a/addons/test_sale_product_configurators/tests/test_sale_product_configurator.py +++ b/addons/test_sale_product_configurators/tests/test_sale_product_configurator.py @@ -87,12 +87,10 @@ class TestProductConfiguratorUi(HttpCase, TestProductConfiguratorCommon): self.start_tour("/web", 'sale_product_configurator_advanced_tour', login='salesman') - # Ensures some dynamic create variants have been created by the configurator - self.assertEqual(len(product_template.product_variant_ids), 2) + # Ensures dynamic create variants have been created by the configurator + self.assertEqual(len(product_template.product_variant_ids), 1) self.assertEqual( - len(product_template.product_variant_ids.product_template_attribute_value_ids), - 8, - "2 variants are created during the tour, with each 5 PAV, but only 8 distinct PAV." + len(product_template.product_variant_ids.product_template_attribute_value_ids), 5 ) def test_03_product_configurator_edition(self):