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