[MOV] website_sale: move variants logic from sale to website_sale

Currently, `website_sale` uses the same logic as `sale` to show the
product variant configuration.

This commit aims to separate this logic and let each module handle its
own product variant configuration.

task-3056806

Part-of: odoo/odoo#106511
This commit is contained in:
Valentin Chevalier
2023-04-24 08:38:11 +02:00
parent 204d4ebf5e
commit 7e42f381c3
23 changed files with 827 additions and 88 deletions
-1
View File
@@ -3,4 +3,3 @@
from . import onboarding
from . import portal
from . import variant
-39
View File
@@ -1,39 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from odoo import http
from odoo.http import request
class VariantController(http.Controller):
@http.route(['/sale/get_combination_info'], type='json', auth="user", methods=['POST'])
def get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist_id, **kw):
combination = request.env['product.template.attribute.value'].browse(combination)
pricelist = self._get_pricelist(pricelist_id)
cids = request.httprequest.cookies.get('cids', str(request.env.user.company_id.id))
allowed_company_ids = [int(cid) for cid in cids.split(',')]
ProductTemplate = request.env['product.template'].with_context(allowed_company_ids=allowed_company_ids)
if 'context' in kw:
ProductTemplate = ProductTemplate.with_context(**kw.get('context'))
product_template = ProductTemplate.browse(int(product_template_id))
res = product_template._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist)
if 'parent_combination' in kw:
parent_combination = request.env['product.template.attribute.value'].browse(kw.get('parent_combination'))
if not combination.exists() and product_id:
product = request.env['product.product'].browse(int(product_id))
if product.exists():
combination = product.product_template_attribute_value_ids
res.update({
'is_combination_possible': product_template._is_combination_possible(combination=combination, parent_combination=parent_combination),
'parent_exclusions': product_template._get_parent_attribute_exclusions(parent_combination=parent_combination)
})
return res
@http.route(['/sale/create_product_variant'], type='json', auth="user", methods=['POST'])
def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs):
return request.env['product.template'].browse(int(product_template_id)).create_product_variant(json.loads(product_template_attribute_value_ids))
def _get_pricelist(self, pricelist_id, pricelist_fallback=False):
return request.env['product.pricelist'].browse(int(pricelist_id or 0))
@@ -17,7 +17,6 @@ It also enables the "optional products" feature.
'data': [
'views/product_template_views.xml',
'views/sale_order_views.xml',
'views/templates.xml',
],
'demo': [
'data/sale_demo.xml',
+3 -2
View File
@@ -41,6 +41,7 @@
'views/website_sale_delivery_templates.xml',
'views/website_sale_menus.xml',
'views/website_sale_delivery_views.xml',
'views/variant_templates.xml',
],
'demo': [
'data/demo.xml',
@@ -58,8 +59,7 @@
'website_sale/static/src/scss/website_sale_delivery.scss',
'website/static/lib/multirange/multirange_custom.scss',
'sale/static/src/scss/sale_portal.scss',
'sale/static/src/scss/product_configurator.scss',
'sale/static/src/js/variant_mixin.js',
'website_sale/static/src/js/sale_variant_mixin.js',
'website_sale/static/src/js/variant_mixin.js',
'website_sale/static/src/js/website_sale.js',
'website_sale/static/src/xml/website_sale.xml',
@@ -80,6 +80,7 @@
'website_sale/static/src/xml/website_sale_reorder_modal.xml',
'website_sale/static/src/js/website_sale_reorder.js',
'website_sale/static/src/js/website_sale_delivery.js',
'website_sale/static/src/scss/product_configurator.scss',
],
'web._assets_primary_variables': [
'website_sale/static/src/scss/primary_variables.scss',
+35 -21
View File
@@ -1,33 +1,47 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request
# Part of Odoo. See LICENSE file for full copyright and licensing details
from odoo.addons.sale.controllers.variant import VariantController
import json
from odoo.http import request, route, Controller
class WebsiteSaleVariantController(VariantController):
@http.route(['/sale/get_combination_info_website'], type='json', auth="public", methods=['POST'], website=True)
class WebsiteSaleVariantController(Controller):
@route('/sale/get_combination_info_website', type='json', auth="public", methods=['POST'], website=True)
def get_combination_info_website(self, product_template_id, product_id, combination, add_qty, **kw):
"""Special route to use website logic in get_combination_info override.
This route is called in JS by appending _website to the base route.
"""
kw.pop('pricelist_id')
combination = self.get_combination_info(product_template_id, product_id, combination, add_qty, request.website.get_current_pricelist(), **kw)
"""Old product configurator logic, only used by frontend configurator, will be deprecated soon"""
combination = request.env['product.template.attribute.value'].browse(combination)
pricelist = request.website.get_current_pricelist()
cids = request.httprequest.cookies.get('cids', str(request.env.user.company_id.id))
allowed_company_ids = [int(cid) for cid in cids.split(',')]
ProductTemplate = request.env['product.template'].with_context(allowed_company_ids=allowed_company_ids)
if 'context' in kw:
ProductTemplate = ProductTemplate.with_context(**kw.get('context'))
product_template = ProductTemplate.browse(int(product_template_id))
combination_info = product_template._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist)
if 'parent_combination' in kw:
parent_combination = request.env['product.template.attribute.value'].browse(kw.get('parent_combination'))
if not combination.exists() and product_id:
product = request.env['product.product'].browse(int(product_id))
if product.exists():
combination = product.product_template_attribute_value_ids
combination_info.update({
'is_combination_possible': product_template._is_combination_possible(combination=combination, parent_combination=parent_combination),
'parent_exclusions': product_template._get_parent_attribute_exclusions(parent_combination=parent_combination)
})
if request.website.google_analytics_key:
combination['product_tracking_info'] = request.env['product.template'].get_google_analytics_data(combination)
combination_info['product_tracking_info'] = request.env['product.template'].get_google_analytics_data(combination_info)
if request.website.product_page_image_width != 'none' and not request.env.context.get('website_sale_no_images', False):
carousel_view = request.env['ir.ui.view']._render_template('website_sale.shop_product_images', values={
'product': request.env['product.template'].browse(combination['product_template_id']),
'product_variant': request.env['product.product'].browse(combination['product_id']),
combination_info['carousel'] = request.env['ir.ui.view']._render_template('website_sale.shop_product_images', values={
'product': request.env['product.template'].browse(combination_info['product_template_id']),
'product_variant': request.env['product.product'].browse(combination_info['product_id']),
'website': request.env['website'].get_current_website(),
})
combination['carousel'] = carousel_view
return combination
return combination_info
@http.route(auth="public")
@route('/sale/create_product_variant', type='json', auth="public", methods=['POST'])
def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs):
"""Override because on the website the public user must access it."""
return super(WebsiteSaleVariantController, self).create_product_variant(product_template_id, product_template_attribute_value_ids, **kwargs)
"""Old product configurator logic, only used by frontend configurator, will be deprecated soon"""
return request.env['product.template'].browse(int(product_template_id)).create_product_variant(json.loads(product_template_attribute_value_ids))
@@ -1,4 +1,4 @@
/** @odoo-module alias=sale.VariantMixin **/
/** @odoo-module alias=website_sale.SaleVariantMixin **/
import concurrency from "web.concurrency";
import core from "web.core";
@@ -1,6 +1,6 @@
/** @odoo-module alias=website_sale.VariantMixin **/
import VariantMixin from "sale.VariantMixin";
import VariantMixin from "website_sale.SaleVariantMixin";
/**
* Website behavior is slightly different from backend so we append
+1 -1
View File
@@ -958,7 +958,7 @@
<input type="hidden" class="product_template_id" name="product_template_id" t-att-value="product.id" />
<input t-if="product.public_categ_ids.ids" type="hidden" class="product_category_id" name="product_category_id" t-att-value="product.public_categ_ids.ids[0]" />
<input t-if="product.product_tag_ids.ids" type="hidden" class="product_tag_id" name="product_tag_id" t-att-value="product.product_tag_ids.ids[0]" />
<t t-if="combination" t-call="sale.variants">
<t t-if="combination" t-call="website_sale.variants">
<t t-set="ul_class" t-valuef="flex-column" />
<t t-set="parent_combination" t-value="None" />
</t>
@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="variants">
<t t-set="attribute_exclusions" t-value="product._get_attribute_exclusions(parent_combination)"/>
<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="website_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="website_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="website_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>
@@ -4,7 +4,7 @@ import concurrency from "web.concurrency";
import core from "web.core";
import publicWidget from "web.public.widget";
import {getCookie, setCookie} from "web.utils.cookies";
import VariantMixin from "sale.VariantMixin";
import VariantMixin from "website_sale.SaleVariantMixin";
import website_sale_utils from "website_sale.utils";
const cartHandlerMixin = website_sale_utils.cartHandlerMixin;
@@ -305,7 +305,7 @@ publicWidget.registry.ProductComparison = publicWidget.Widget.extend(cartHandler
const $form = $(ev.currentTarget);
const cellIndex = $(ev.currentTarget).closest('td')[0].cellIndex;
this.getCartHandlerOptions(ev);
// Override product image container for animation.
// Override product image container for animation.
this.$itemImgContainer = this.$('#o_comparelist_table tr').first().find('td').eq(cellIndex);
const $inputProduct = $form.find('input[type="hidden"][name="product_id"]').first();
const productId = parseInt($inputProduct.val());
@@ -11,6 +11,7 @@
'depends': ['website_sale', 'sale_product_configurator'],
'auto_install': True,
'data': [
'views/templates.xml',
'views/website_sale_product_configurator_templates.xml',
],
'demo': [
@@ -18,9 +19,9 @@
],
'assets': {
'web.assets_frontend': [
('before', 'website_sale/static/src/js/website_sale.js', 'sale_product_configurator/static/src/js/product_configurator_modal.js'),
('before', 'website_sale/static/src/js/website_sale.js', 'website_sale_product_configurator/static/src/js/sale_product_configurator_modal.js'),
('before', 'website_sale/static/src/js/website_sale.js', 'website_sale_product_configurator/static/src/js/product_configurator_modal.js'),
'sale/static/src/scss/product_configurator.scss',
'website_sale/static/src/scss/product_configurator.scss',
'website_sale_product_configurator/static/src/scss/website_sale_options.scss',
'website_sale_product_configurator/static/src/js/website_sale_options.js',
],
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
from . import main
from . import sale_product_configurator
@@ -4,7 +4,7 @@ import json
from odoo import http
from odoo.http import request
from odoo.addons.sale_product_configurator.controllers.main import ProductConfiguratorController
from odoo.addons.website_sale_product_configurator.controllers.sale_product_configurator import ProductConfiguratorController
from odoo.addons.website_sale.controllers import main
@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request
class ProductConfiguratorController(http.Controller):
@http.route(['/sale_product_configurator/configure'], type='json', auth="user", methods=['POST'])
def configure(self, product_template_id, pricelist_id, **kw):
add_qty = float(kw.get('add_qty', 1))
product_template = request.env['product.template'].browse(int(product_template_id))
pricelist = self._get_pricelist(pricelist_id)
product_combination = False
attribute_value_ids = set(kw.get('product_template_attribute_value_ids', []))
attribute_value_ids |= set(kw.get('product_no_variant_attribute_value_ids', []))
if attribute_value_ids:
product_combination = request.env['product.template.attribute.value'].browse(
attribute_value_ids
).filtered(
lambda ptav: ptav.product_tmpl_id == product_template
) # Filter out ptavs not belonging to the given template
# It happens when you change the template on an already configured line
# receiving the configured attributes data from the previous template configuration.
if pricelist:
product_template = product_template.with_context(pricelist=pricelist.id, partner=request.env.user.partner_id)
return request.env['ir.ui.view']._render_template(
"website_sale_product_configurator.configure",
{
'product': product_template,
'pricelist': pricelist,
'add_qty': add_qty,
'product_combination': product_combination
},
)
@http.route(['/sale_product_configurator/show_advanced_configurator'], type='json', auth="user", methods=['POST'])
def show_advanced_configurator(self, product_id, variant_values, pricelist_id, **kw):
pricelist = self._get_pricelist(pricelist_id)
return self._show_advanced_configurator(product_id, variant_values, pricelist, False, **kw)
@http.route(['/sale_product_configurator/optional_product_items'], type='json', auth="user", methods=['POST'])
def optional_product_items(self, product_id, pricelist_id, **kw):
pricelist = self._get_pricelist(pricelist_id)
return self._optional_product_items(product_id, pricelist, **kw)
def _optional_product_items(self, product_id, pricelist, **kw):
add_qty = float(kw.get('add_qty', 1))
product = request.env['product.product'].browse(int(product_id))
parent_combination = product.product_template_attribute_value_ids
if product.env.context.get('no_variant_attribute_values'):
# Add "no_variant" attribute values' exclusions
# They are kept in the context since they are not linked to this product variant
parent_combination |= product.env.context.get('no_variant_attribute_values')
return request.env['ir.ui.view']._render_template("website_sale_product_configurator.optional_product_items", {
'product': product,
'parent_name': product.name,
'parent_combination': parent_combination,
'pricelist': pricelist,
'add_qty': add_qty,
})
def _show_advanced_configurator(self, product_id, variant_values, pricelist, handle_stock, **kw):
product = request.env['product.product'].browse(int(product_id))
combination = request.env['product.template.attribute.value'].browse(variant_values)
add_qty = float(kw.get('add_qty', 1))
no_variant_attribute_values = combination.filtered(
lambda product_template_attribute_value: product_template_attribute_value.attribute_id.create_variant == 'no_variant'
)
if no_variant_attribute_values:
product = product.with_context(no_variant_attribute_values=no_variant_attribute_values)
return request.env['ir.ui.view']._render_template("website_sale_product_configurator.optional_products_modal", {
'product': product,
'combination': combination,
'add_qty': add_qty,
'parent_name': product.name,
'variant_values': variant_values,
'pricelist': pricelist,
'handle_stock': handle_stock,
'already_configured': kw.get("already_configured", False),
'mode': kw.get('mode', 'add'),
'product_custom_attribute_values': kw.get('product_custom_attribute_values', None),
'no_attribute': kw.get('no_attribute', False),
'custom_attribute': kw.get('custom_attribute', False)
})
def _get_pricelist(self, pricelist_id, pricelist_fallback=False):
return request.env['product.pricelist'].browse(int(pricelist_id or 0))
@@ -1,6 +1,6 @@
/** @odoo-module **/
import { OptionalProductsModal } from "@sale_product_configurator/js/product_configurator_modal";
import { OptionalProductsModal } from "@website_sale_product_configurator/js/sale_product_configurator_modal";
OptionalProductsModal.include({
/**
@@ -0,0 +1,525 @@
/** @odoo-module */
import ajax from 'web.ajax';
import Dialog from 'web.Dialog';
import OwlDialog from 'web.OwlDialog';
import ServicesMixin from 'web.ServicesMixin';
import VariantMixin from 'website_sale.SaleVariantMixin';
export const OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
events: Object.assign({}, Dialog.prototype.events, VariantMixin.events, {
'click a.js_add, a.js_remove': '_onAddOrRemoveOption',
'click button.js_add_cart_json': 'onClickAddCartJSON',
'change .in_cart input.js_quantity': '_onChangeQuantity',
'change .js_raw_price': '_computePriceTotal'
}),
/**
* Initializes the optional products modal
*
* @override
* @param {$.Element} parent The parent container
* @param {Object} params
* @param {integer} params.pricelistId
* @param {string} params.okButtonText The text to apply on the "ok" button, typically
* "Add" for the sale order and "Proceed to checkout" on the web shop
* @param {string} params.cancelButtonText same as "params.okButtonText" but
* for the cancel button
* @param {integer} params.previousModalHeight used to configure a min height on the modal-content.
* This parameter is provided by the product configurator to "cover" its modal by making
* this one big enough. This way the user can't see multiple buttons (which can be confusing).
* @param {Object} params.rootProduct The root product of the optional products window
* @param {integer} params.rootProduct.product_id
* @param {integer} params.rootProduct.quantity
* @param {Array} params.rootProduct.variant_values
* @param {Array} params.rootProduct.product_custom_attribute_values
* @param {Array} params.rootProduct.no_variant_attribute_values
*/
init: function (parent, params) {
var self = this;
var options = Object.assign({
size: 'large',
buttons: [{
text: params.okButtonText,
click: this._onConfirmButtonClick,
// the o_sale_product_configurator_edit class is used for tours.
classes: 'btn-primary o_sale_product_configurator_edit'
}, {
text: params.cancelButtonText,
click: this._onCancelButtonClick
}],
technical: !params.isWebsite,
}, params || {});
this._super(parent, options);
this.context = params.context;
this.rootProduct = params.rootProduct;
this.container = parent;
this.pricelistId = params.pricelistId;
this.previousModalHeight = params.previousModalHeight;
this.mode = params.mode;
this.dialogClass = 'oe_advanced_configurator_modal';
this._productImageField = 'image_128';
this._opened.then(function () {
if (self.previousModalHeight) {
self.$el.closest('.modal-content').css('min-height', self.previousModalHeight + 'px');
}
});
},
/**
* @override
*/
willStart: function () {
var self = this;
var uri = this._getUri("/sale_product_configurator/show_advanced_configurator");
var getModalContent = ajax.jsonRpc(uri, 'call', {
mode: self.mode,
product_id: self.rootProduct.product_id,
variant_values: self.rootProduct.variant_values,
product_custom_attribute_values: self.rootProduct.product_custom_attribute_values,
pricelist_id: self.pricelistId || false,
add_qty: self.rootProduct.quantity,
force_dialog: self.forceDialog,
no_attribute: self.rootProduct.no_variant_attribute_values,
custom_attribute: self.rootProduct.product_custom_attribute_values,
context: Object.assign({'quantity': self.rootProduct.quantity}, this.context),
})
.then(function (modalContent) {
if (modalContent) {
var $modalContent = $(modalContent);
$modalContent = self._postProcessContent($modalContent);
self.$content = $modalContent;
} else {
self.trigger('options_empty');
self.preventOpening = true;
}
});
var parentInit = self._super.apply(self, arguments);
return Promise.all([getModalContent, parentInit]);
},
/**
* This is overridden to append the modal to the provided container (see init("parent")).
* We need this to have the modal contained in the web shop product form.
* The additional products data will then be contained in the form and sent on submit.
*
* @override
*/
open: function (options) {
$('.tooltip').remove(); // remove open tooltip if any to prevent them staying when modal is opened
var self = this;
this.appendTo($('<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;
},
});
@@ -3,7 +3,7 @@
import ajax from "web.ajax";
import core from "web.core";
import publicWidget from "web.public.widget";
import { OptionalProductsModal } from "@sale_product_configurator/js/product_configurator_modal";
import { OptionalProductsModal } from "@website_sale_product_configurator/js/sale_product_configurator_modal";
import "website_sale.website_sale";
import wsUtils from "website_sale.utils";
@@ -2,7 +2,7 @@
<odoo>
<template id="optional_products_modal" name="Optional Products">
<main class="modal-body">
<t t-call="sale_product_configurator.configure_optional_products" />
<t t-call="website_sale_product_configurator.configure_optional_products" />
</main>
</template>
@@ -38,7 +38,7 @@
<div class="col-lg-12 text-center mt-5">
<t t-if="product._is_add_to_cart_possible()">
<div class="col-lg-5 d-inline-block text-start">
<t t-if="combination" t-call="sale.variants">
<t t-if="combination" t-call="website_sale.variants">
<t t-set="parent_combination" t-value="None"/>
</t>
<h2>
@@ -57,7 +57,7 @@
}'/>
</h2>
<t t-if="product.visible_qty_configurator">
<t t-call="sale_product_configurator.product_quantity_config" />
<t t-call="website_sale_product_configurator.product_quantity_config" />
</t>
<p class="css_not_available_msg alert alert-warning">This combination does not exist.</p>
</div>
@@ -118,7 +118,7 @@
<t t-if="product.product_tmpl_id and not combination">
<t t-set="combination" t-value="product.product_tmpl_id._get_first_possible_combination()"/>
</t>
<t t-if="combination and not already_configured" t-call="sale.variants">
<t t-if="combination and not already_configured" t-call="website_sale.variants">
<t t-set="ul_class" t-valuef="flex-column" />
<t t-set="product" t-value="product.product_tmpl_id"/>
</t>
@@ -132,7 +132,7 @@
</div>
</td>
<td class="text-center td-qty">
<t t-call='sale_product_configurator.product_quantity_config' />
<t t-call='website_sale_product_configurator.product_quantity_config' />
</td>
<td class="text-center td-price" name="price">
<div t-attf-class="text-danger oe_default_price oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
@@ -167,7 +167,7 @@
</tr>
<t t-if="product.optional_product_ids and mode != 'edit'">
<tr class="o_select_options"><td colspan="4"><h4>Available Options:</h4></td></tr>
<t t-call="sale_product_configurator.optional_product_items">
<t t-call="website_sale_product_configurator.optional_product_items">
<t t-set="parent_combination" t-value="combination"/>
</t>
</t>
@@ -196,12 +196,12 @@
<strong class="product-name product_display_name" t-out="combination_info['display_name']"/>
<div class="text-muted small" t-field="product.description_sale"/>
</div>
<t t-call="sale.variants">
<t t-call="website_sale.variants">
<t t-set="combination" t-value="product._get_first_possible_combination(parent_combination)"/>
</t>
</td>
<td class="text-center td-qty d-none">
<t t-call='sale_product_configurator.product_quantity_config' />
<t t-call='website_sale_product_configurator.product_quantity_config' />
</td>
<td class="text-center td-price">
<div t-attf-class="text-danger oe_default_price oe_optional oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="configure_optional_products_website" inherit_id="sale_product_configurator.configure_optional_products">
<template id="configure_optional_products_website" inherit_id="website_sale_product_configurator.configure_optional_products">
<xpath expr="//th[hasclass('td-qty')]/span[hasclass('label')]" position="attributes">
<attribute name='t-if'>not request.is_frontend or (request.is_frontend and is_view_active('website_sale.product_quantity'))</attribute>
</xpath>
</template>
<template id="product_quantity_config_website" inherit_id="sale_product_configurator.product_quantity_config" priority="18">
<template id="product_quantity_config_website" inherit_id="website_sale_product_configurator.product_quantity_config" priority="18">
<xpath expr="//div[hasclass('css_quantity')]" position="attributes">
<attribute name='t-if'>not request.is_frontend or (request.is_frontend and is_view_active('website_sale.product_quantity'))</attribute>
</xpath>
@@ -1,7 +1,7 @@
/** @odoo-module alias=website_sale_stock.VariantMixin **/
import {Markup} from "web.utils";
import VariantMixin from "sale.VariantMixin";
import VariantMixin from "website_sale.SaleVariantMixin";
import publicWidget from "web.public.widget";
import core from "web.core";
var QWeb = core.qweb;
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="website_sale_stock_modal" inherit_id="sale_product_configurator.product_quantity_config" name="Stocks Modal">
<template id="website_sale_stock_modal" inherit_id="website_sale_product_configurator.product_quantity_config" name="Stocks Modal">
<xpath expr="//input[@type='text'][hasclass('quantity')]" position="attributes">
<attribute name='t-att-data-max'>max(product.sudo()._get_max_available_qty(), 1) if handle_stock and product.type == "product" and not product.allow_out_of_stock_order else None</attribute>
</xpath>
@@ -2,7 +2,7 @@
import publicWidget from "web.public.widget";
import wSaleUtils from "website_sale.utils";
import VariantMixin from "sale.VariantMixin";
import VariantMixin from "website_sale.SaleVariantMixin";
// VariantMixin events are overridden on purpose here
// to avoid registering them more than once since they are already registered