[IMP] product,sale(_product_configurator): refactor in OWL
The product configurator dialog was still in legacy code as it was not required for the migration of the views to OWL. Now that we're moving forward with the removal of other parts of the legacy code, this dialog had to be converted as well. Some parts of the logic have now been rewritten in OWL, getting a better share of the logic between the server and the client. The product configurator in e-commerce still uses the old implementation and will be refactored in another task. task-3056806 closes odoo/odoo#106511 Related: odoo/enterprise#39525 Related: odoo/upgrade#4550 Signed-off-by: Valentin Chevalier <vcr@odoo.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="variants">
|
||||
<t t-set="attribute_exclusions" t-value="product._get_attribute_exclusions(parent_combination, parent_name)"/>
|
||||
<ul t-attf-class="list-unstyled js_add_cart_variants mb-0 #{ul_class}" t-att-data-attribute_exclusions="json.dumps(attribute_exclusions)">
|
||||
<t t-foreach="product.valid_product_template_attribute_line_ids" t-as="ptal">
|
||||
<!-- Attributes selection is hidden if there is only one value available and it's not a custom value -->
|
||||
<li t-att-data-attribute_id="ptal.attribute_id.id"
|
||||
t-att-data-attribute_name="ptal.attribute_id.name"
|
||||
t-att-data-attribute_display_type="ptal.attribute_id.display_type"
|
||||
t-attf-class="variant_attribute #{'d-none' if len(ptal.product_template_value_ids._only_active()) == 1 and not ptal.product_template_value_ids._only_active()[0].is_custom else ''}">
|
||||
|
||||
<!-- Used to customize layout if the only available attribute value is custom -->
|
||||
<t t-set="single" t-value="len(ptal.product_template_value_ids._only_active()) == 1"/>
|
||||
<t t-set="single_and_custom" t-value="single and ptal.product_template_value_ids._only_active()[0].is_custom" />
|
||||
<strong t-field="ptal.attribute_id.name" class="attribute_name"/>
|
||||
|
||||
<t t-if="ptal.attribute_id.display_type == 'select'">
|
||||
<select
|
||||
t-att-data-attribute_id="ptal.attribute_id.id"
|
||||
t-attf-class="form-select css_attribute_select o_wsale_product_attribute js_variant_change #{ptal.attribute_id.create_variant} #{'d-none' if single_and_custom else ''}"
|
||||
t-att-name="'ptal-%s' % ptal.id">
|
||||
<t t-foreach="ptal.product_template_value_ids._only_active()" t-as="ptav">
|
||||
<option t-att-value="ptav.id"
|
||||
t-att-data-value_id="ptav.id"
|
||||
t-att-data-value_name="ptav.name"
|
||||
t-att-data-attribute_name="ptav.attribute_id.name"
|
||||
t-att-data-is_custom="ptav.is_custom"
|
||||
t-att-selected="ptav in combination"
|
||||
t-att-data-is_single="single"
|
||||
t-att-data-is_single_and_custom="single_and_custom">
|
||||
<span t-field="ptav.name"/>
|
||||
<t t-call="sale.badge_extra_price"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</t>
|
||||
|
||||
<t t-if="ptal.attribute_id.display_type == 'radio'">
|
||||
<ul t-att-data-attribute_id="ptal.attribute_id.id" t-attf-class="list-inline list-unstyled o_wsale_product_attribute #{'d-none' if single_and_custom else ''}">
|
||||
<t t-foreach="ptal.product_template_value_ids._only_active()" t-as="ptav">
|
||||
<li class="list-inline-item mb-3 js_attribute_value" style="margin: 0;">
|
||||
<label class="col-form-label">
|
||||
<div class="form-check">
|
||||
<input type="radio"
|
||||
t-attf-class="form-check-input js_variant_change #{ptal.attribute_id.create_variant}"
|
||||
t-att-checked="ptav in combination"
|
||||
t-att-name="'ptal-%s' % ptal.id"
|
||||
t-att-value="ptav.id"
|
||||
t-att-data-value_id="ptav.id"
|
||||
t-att-data-value_name="ptav.name"
|
||||
t-att-data-attribute_name="ptav.attribute_id.name"
|
||||
t-att-data-is_custom="ptav.is_custom"
|
||||
t-att-data-is_single="single"
|
||||
t-att-data-is_single_and_custom="single_and_custom" />
|
||||
<div class="radio_input_value form-check-label">
|
||||
<span t-field="ptav.name"/>
|
||||
<t t-call="sale.badge_extra_price"/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
|
||||
<t t-if="ptal.attribute_id.display_type == 'pills'">
|
||||
<ul t-att-data-attribute_id="ptal.attribute_id.id"
|
||||
t-attf-class="btn-group-toggle list-inline list-unstyled o_wsale_product_attribute #{'d-none' if single_and_custom else ''}"
|
||||
data-bs-toggle="buttons">
|
||||
<t t-foreach="ptal.product_template_value_ids._only_active()" t-as="ptav">
|
||||
<li t-attf-class="o_variant_pills btn btn-primary mb-1 list-inline-item js_attribute_value #{'active' if ptav in combination else ''}">
|
||||
<input type="radio"
|
||||
t-attf-class="js_variant_change #{ptal.attribute_id.create_variant}"
|
||||
t-att-checked="ptav in combination"
|
||||
t-att-name="'ptal-%s' % ptal.id"
|
||||
t-att-value="ptav.id"
|
||||
t-att-data-value_id="ptav.id"
|
||||
t-att-id="ptav.id"
|
||||
t-att-data-value_name="ptav.name"
|
||||
t-att-data-attribute_name="ptav.attribute_id.name"
|
||||
t-att-data-is_custom="ptav.is_custom"
|
||||
t-att-data-is_single_and_custom="single_and_custom"
|
||||
t-att-autocomplete="off"/>
|
||||
<div class="radio_input_value o_variant_pills_input_value">
|
||||
<span t-field="ptav.name"/>
|
||||
<t t-call="sale.badge_extra_price"/>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
|
||||
<t t-if="ptal.attribute_id.display_type == 'color'">
|
||||
<ul t-att-data-attribute_id="ptal.attribute_id.id" t-attf-class="list-inline o_wsale_product_attribute #{'d-none' if single_and_custom else ''}">
|
||||
<li t-foreach="ptal.product_template_value_ids._only_active()" t-as="ptav" class="list-inline-item me-1">
|
||||
<label t-attf-style="background-color:#{ptav.html_color or ptav.product_attribute_value_id.name if not ptav.is_custom else ''}"
|
||||
t-attf-class="css_attribute_color #{'active' if ptav in combination else ''} #{'custom_value' if ptav.is_custom else ''} #{'transparent' if (not ptav.is_custom and not ptav.html_color) else ''}">
|
||||
<input type="radio"
|
||||
t-attf-class="js_variant_change #{ptal.attribute_id.create_variant}"
|
||||
t-att-checked="ptav in combination"
|
||||
t-att-name="'ptal-%s' % ptal.id"
|
||||
t-att-value="ptav.id"
|
||||
t-att-title="ptav.name"
|
||||
t-att-data-value_id="ptav.id"
|
||||
t-att-data-value_name="ptav.name"
|
||||
t-att-data-attribute_name="ptav.attribute_id.name"
|
||||
t-att-data-is_custom="ptav.is_custom"
|
||||
t-att-data-is_single="single"
|
||||
t-att-data-is_single_and_custom="single_and_custom"/>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</template>
|
||||
<template id="badge_extra_price" name="Badge Extra Price">
|
||||
<t t-set="combination_info_variant" t-value="product._get_combination_info(ptav, pricelist=pricelist)"/>
|
||||
<span class="badge rounded-pill text-bg-light border" t-if="combination_info_variant['price_extra']">
|
||||
<!--
|
||||
price_extra is displayed as catalog price instead of
|
||||
price after pricelist because it is impossible to
|
||||
compute. Indeed, the pricelist rule might depend on the
|
||||
selected variant, so the price_extra will be different
|
||||
depending on the selected combination. The price of an
|
||||
attribute is therefore variable and it's not very
|
||||
accurate to display it.
|
||||
|
||||
To cover some generic cases, the price_extra also
|
||||
covers the price-included taxes in e-commerce flows.
|
||||
(See the override of `_get_combination_info`)
|
||||
-->
|
||||
<span class="sign_badge_price_extra" t-out="combination_info_variant['price_extra'] > 0 and '+' or '-'"/>
|
||||
<span t-out="abs(combination_info_variant['price_extra'])" class="variant_price_extra text-muted fst-italic" style="white-space: nowrap;"
|
||||
t-options='{
|
||||
"widget": "monetary",
|
||||
"display_currency": (pricelist or product.env.company).currency_id
|
||||
}'/>
|
||||
</span>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import main
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
+9
@@ -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%;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="product.badge_extra_price" owl="1">
|
||||
<span class="badge rounded-pill text-bg-light border ms-1 ps-1">
|
||||
<span t-out="this.props.price > 0 ? '+' : '-'" class="o_product_badge_extra_price"/>
|
||||
<span t-out="getFormattedPrice()" class="text-muted fst-italic"/>
|
||||
</span>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="sale_product_configurator.product" owl="1">
|
||||
<td class="o_sale_product_configurator_img">
|
||||
<img
|
||||
t-if="this.props.id"
|
||||
t-att-src="'/web/image/product.product/'+this.props.id+'/image_128'"
|
||||
alt="Product Image"/>
|
||||
<img
|
||||
t-else=""
|
||||
t-att-src="'/web/image/product.template/'+this.props.product_tmpl_id+'/image_128'"
|
||||
alt="Product Image"/>
|
||||
</td>
|
||||
<td t-att-colspan="this.props.optional ? 2:false">
|
||||
<div class="o_sale_product_configurator_name">
|
||||
<strong t-out="this.props.display_name"/>
|
||||
<div
|
||||
t-if="this.props.description_sale"
|
||||
t-out="this.props.description_sale"
|
||||
class="text-muted small"/>
|
||||
</div>
|
||||
<t t-foreach="this.props.attribute_lines" t-as="ptal" t-key="ptal.id">
|
||||
<PTAL t-props="ptal" productTmplId="this.props.product_tmpl_id"/>
|
||||
</t>
|
||||
</td>
|
||||
<td t-if="!this.props.optional" class="text-center o_sale_product_configurator_qty">
|
||||
<div class="input-group">
|
||||
<button
|
||||
class="btn btn-primary d-none d-md-inline-block"
|
||||
aria-label="Remove one"
|
||||
t-on-click="decreaseQuantity">
|
||||
<i class="fa fa-minus"></i>
|
||||
</button>
|
||||
<input
|
||||
name="product_quantity"
|
||||
type="number"
|
||||
t-att-value="this.props.quantity"
|
||||
t-on-change="setQuantity"/>
|
||||
<button
|
||||
class="btn btn-primary d-none d-md-inline-block"
|
||||
aria-label="Add one"
|
||||
t-on-click="increaseQuantity">
|
||||
<i class="fa fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center o_sale_product_configurator_price" name="price">
|
||||
<t t-if="this.env.isPossibleCombination(this.props)">
|
||||
<span style="white-space: nowrap;" t-out="getFormattedPrice()"/>
|
||||
<button
|
||||
t-if="this.props.optional"
|
||||
class="btn btn-primary btn-sm"
|
||||
t-on-click="() => this.env.addProduct(this.props.product_tmpl_id)">
|
||||
<i class="fa fa-shopping-cart"></i> Add to cart
|
||||
</button>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p class="alert alert-warning">Option not available</p>
|
||||
</t>
|
||||
<div>
|
||||
<a
|
||||
role="button"
|
||||
t-if="!this.props.optional && this.env.mainProductTmplId !== this.props.product_tmpl_id"
|
||||
t-on-click="() => this.env.removeProduct(this.props.product_tmpl_id)">
|
||||
<i class="fa fa-trash-o"/>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</t>
|
||||
</templates>
|
||||
+454
@@ -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();
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="sale_product_configurator.dialog" owl="1">
|
||||
<Dialog size="size" title="title">
|
||||
<ProductList t-if="this.state.products.length" products="this.state.products"/>
|
||||
<ProductList
|
||||
t-if="this.state.optionalProducts.length"
|
||||
products="this.state.optionalProducts"
|
||||
areProductsOptional="true"/>
|
||||
<t t-set-slot="footer">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
t-on-click="onConfirm"
|
||||
t-att-disabled="!isPossibleConfiguration()">
|
||||
Confirm
|
||||
</button>
|
||||
<button class="btn btn-secondary" t-on-click="onDiscard">
|
||||
Back
|
||||
</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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($('<div/>')).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 = $('<div/>');
|
||||
$updatedDescription.append($('<p>', {
|
||||
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($('<div>', {
|
||||
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 = $('<div>', {
|
||||
class: 'custom_attribute_values_description text-muted small'
|
||||
});
|
||||
if (productCustomVariantValues.length !== 0 || noVariantAttributeValues.length !== 0) {
|
||||
$customAttributeValuesDescription.append($('<br/>'));
|
||||
}
|
||||
|
||||
$.each(productCustomVariantValues, function (){
|
||||
$customAttributeValuesDescription.append($('<div>', {
|
||||
text: this.attribute_value_name + ': ' + this.custom_value
|
||||
}));
|
||||
});
|
||||
|
||||
$.each(noVariantAttributeValues, function (){
|
||||
if (this.is_custom !== 'True'){
|
||||
$customAttributeValuesDescription.append($('<div>', {
|
||||
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;
|
||||
},
|
||||
});
|
||||
@@ -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},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="saleProductConfigurator.productList" owl="1">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr t-if="!this.props.areProductsOptional">
|
||||
<th class="o_product_configurator_img"><span class="label">Product</span></th>
|
||||
<th><span class="label"></span></th>
|
||||
<th class="text-center o_product_configurator_qty">
|
||||
<span class="label">Quantity</span>
|
||||
</th>
|
||||
<th class="text-center o_product_configurator_price">
|
||||
<span class="label">Price</span>
|
||||
</th>
|
||||
</tr>
|
||||
<tr t-else=""><th colspan="4"><h4>Available Options:</h4></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="this.props.products" t-as="product" t-key="product.product_tmpl_id">
|
||||
<Product t-props="product" optional="this.props.areProductsOptional"/>
|
||||
</tr>
|
||||
<tr t-if="!this.props.areProductsOptional">
|
||||
<td colspan="4" class="text-end">
|
||||
<strong>Total: </strong>
|
||||
<span class="fw-bold" t-out="getFormattedTotal()"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
</templates>
|
||||
+132
@@ -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;
|
||||
}
|
||||
}
|
||||
+98
@@ -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,<svg xmlns='http://www.w3.org/2000/svg' width='39' height='39'><line y2='0' x2='39' y1='39' x1='0' style='stroke:#{map-get($theme-colors, 'danger')};stroke-width:2'/><line y2='1' x2='40' y1='40' x1='1' style='stroke:rgb(255,255,255);stroke-width:1'/></svg>"), "#", "%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;
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<!-- Attributes line template -->
|
||||
<t t-name="saleProductConfigurator.ptal" owl="1">
|
||||
<!-- If the attribute line contains only one attribute value, we don't show the attribute
|
||||
value template or the attribute line title unless the single attribute value is custom,
|
||||
whereas in this case, only the title of the attribute line and the custom value
|
||||
template are rendered. -->
|
||||
<strong
|
||||
t-if="this.props.attribute_values.length === 1 && isSelectedPTAVCustom() || this.props.attribute_values.length > 1"
|
||||
t-out="this.props.attribute.name"
|
||||
class="o_sale_product_configurator_ptal_name"/>
|
||||
<t t-if="this.props.attribute_values.length > 1" t-call="{{getPTAVTemplate()}}"/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="custom"
|
||||
t-if="isSelectedPTAVCustom()"
|
||||
t-att-value="this.props.customValue"
|
||||
t-on-change="updateCustomValue"
|
||||
/>
|
||||
</t>
|
||||
<!-- Attributes value templates -->
|
||||
<t t-name="saleProductConfigurator.ptav-select" owl="1">
|
||||
<select class="form-select"
|
||||
t-on-change="updateSelectedPTAV"
|
||||
t-att-name="'ptal-' + this.props.id">
|
||||
<option
|
||||
t-foreach="this.props.attribute_values"
|
||||
t-as="ptav"
|
||||
t-key="ptav.id"
|
||||
t-att-value="ptav.id"
|
||||
t-att-selected="ptav.id === this.props.selected_attribute_value_id"
|
||||
t-out="getPTAVSelectName(ptav)"
|
||||
t-att-class="{ 'css_not_available': ptav.excluded }"/>
|
||||
</select>
|
||||
</t>
|
||||
<t t-name="saleProductConfigurator.ptav-radio" owl="1">
|
||||
<ul class="list-inline list-unstyled">
|
||||
<li t-foreach="this.props.attribute_values" t-as="ptav" t-key="ptav.id"
|
||||
class="list-inline-item mb-3">
|
||||
<div class="form-check">
|
||||
<label
|
||||
class="col-form-label"
|
||||
t-att-class="{ 'css_not_available': ptav.excluded }"
|
||||
t-att-for="ptav.id">
|
||||
<span t-out="ptav.name"/>
|
||||
<BadgeExtraPrice
|
||||
t-if="ptav.price_extra"
|
||||
price="ptav.price_extra"
|
||||
currencyId="this.env.currencyId"/>
|
||||
</label>
|
||||
<input
|
||||
type="radio"
|
||||
class="form-check-input"
|
||||
t-att-id="ptav.id"
|
||||
t-att-value="ptav.id"
|
||||
t-att-checked="ptav.id === this.props.selected_attribute_value_id"
|
||||
t-att-name="'ptal-' + this.props.id"
|
||||
t-on-change="updateSelectedPTAV"/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
<t t-name="saleProductConfigurator.ptav-pills" owl="1">
|
||||
<ul t-attf-class="list-inline list-unstyled">
|
||||
<li t-foreach="this.props.attribute_values" t-as="ptav" t-key="ptav.id"
|
||||
t-att-class="{'active': ptav.id === this.props.selected_attribute_value_id}"
|
||||
class="o_sale_product_configurator_ptav_pills btn btn-primary list-inline-item mb-3">
|
||||
<div class="form-check">
|
||||
<label
|
||||
class="col-form-label"
|
||||
t-att-class="{ 'css_not_available': ptav.excluded }"
|
||||
t-att-for="ptav.id">
|
||||
<span t-out="ptav.name"/>
|
||||
<BadgeExtraPrice
|
||||
t-if="ptav.price_extra"
|
||||
price="ptav.price_extra"
|
||||
currencyId="this.env.currencyId"/>
|
||||
</label>
|
||||
<input
|
||||
type="radio"
|
||||
t-att-id="ptav.id"
|
||||
t-att-value="ptav.id"
|
||||
t-att-name="'ptal-' + this.props.id"
|
||||
t-att-checked="ptav.id === this.props.selected_attribute_value_id"
|
||||
t-on-change="updateSelectedPTAV"/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
<t t-name="saleProductConfigurator.ptav-color" owl="1">
|
||||
<ul class="list-inline">
|
||||
<li t-foreach="this.props.attribute_values" t-as="ptav" t-key="ptav.id"
|
||||
class="list-inline-item me-1">
|
||||
<label
|
||||
t-attf-style="background-color:#{ptav.is_custom ? '' : ptav.html_color}"
|
||||
t-att-class="{'o_sale_product_configurator_ptav_color': true, 'active': ptav.id === this.props.selected_attribute_value_id, 'custom_value': ptav.is_custom, 'transparent': !ptav.is_custom and !ptav.html_color, 'css_not_available': ptav.excluded }">
|
||||
<input
|
||||
type="radio"
|
||||
t-att-id="ptav.id"
|
||||
t-att-value="ptav.id"
|
||||
t-att-name="'ptal-' + this.props.id"
|
||||
t-att-checked="ptav.id === this.props.selected_attribute_value_id"
|
||||
t-on-change="updateSelectedPTAV"/>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+9
-14
@@ -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',
|
||||
|
||||
+27
-30
@@ -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) {
|
||||
$('<p>').text('image variant option src changed').insertAfter('.oe_advanced_configurator_modal .js_product:eq(1) .product-name');
|
||||
$('<p>').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()
|
||||
]});
|
||||
|
||||
+29
-72
@@ -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(),
|
||||
]});
|
||||
|
||||
+12
-27
@@ -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()
|
||||
]});
|
||||
|
||||
+13
-15
@@ -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")',
|
||||
|
||||
+11
-30
@@ -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()
|
||||
]});
|
||||
|
||||
+19
-19
@@ -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()
|
||||
]});
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user