[REF] sale_product_configurator, *: remove product configurator wizard

*: sale, sale_product_matrix, test_sale_product_configurators,
website_sale_product_configurator

Previously, the product configurator used a transient model and a
corresponding wizard to configure products. In subsequent refactorings,
the product configurator was merged with the optional product modal, and
because this feature also needs to work in the eCommerce, all the
features are available in the modal which doesn't directly depend on the
form view.

Since the modal is capable of doing everything on its own, the form view
and its corresponding transient model are no longer necessary, the m2o
widget can just open the modal directly instead. The only thing that was
missing from the modal was the ability to selectively display the
optional products depending on the configurator mode (we don't want to
show optional products when we're configuring an existing product).

This commit refactors the product_configurator_widget so that it can
open the product_configurator_modal directly without the need for an
intermediate form view, and removes said form view and the corresponding
transient model.

Tours and tests have been adapted to have their selectors always match
the modal.

closes odoo/odoo#99219

Related: odoo/upgrade#3844
Related: odoo/enterprise#30860
Signed-off-by: Victor Feyens (vfe) <vfe@odoo.com>
This commit is contained in:
Samuel Degueldre
2022-09-02 23:16:00 +02:00
parent aa9dcc2bce
commit 52ef83e006
20 changed files with 264 additions and 797 deletions
+2 -4
View File
@@ -206,10 +206,9 @@ var VariantMixin = {
* @param {$.Element} $container
*/
triggerVariantChange: function ($container) {
var self = this;
$container.find('ul[data-attribute_exclusions]').trigger('change');
$container.find('input.js_variant_change:checked, select.js_variant_change').each(function () {
self.handleCustomValues($(this));
VariantMixin.handleCustomValues($(this));
});
},
@@ -312,7 +311,6 @@ var VariantMixin = {
* @returns {Promise} the promise that will be resolved with a {integer} productId
*/
selectOrCreateProduct: function ($container, productId, productTemplateId, useAjax) {
var self = this;
productId = parseInt(productId);
productTemplateId = parseInt(productTemplateId);
var productReady = Promise.resolve();
@@ -322,7 +320,7 @@ var VariantMixin = {
var params = {
product_template_id: productTemplateId,
product_template_attribute_value_ids:
JSON.stringify(self.getSelectedVariantValues($container)),
JSON.stringify(VariantMixin.getSelectedVariantValues($container)),
};
var route = '/sale/create_product_variant';
@@ -2,4 +2,3 @@
from . import controllers
from . import models
from . import wizard
@@ -15,13 +15,9 @@ It also enables the "optional products" feature.
'depends': ['sale'],
'data': [
'security/ir.model.access.csv',
'views/product_template_views.xml',
'views/sale_order_views.xml',
'views/templates.xml',
'wizard/sale_product_configurator_views.xml',
],
'demo': [
'data/sale_demo.xml',
@@ -29,9 +25,6 @@ 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_renderer.js',
'sale_product_configurator/static/src/js/product_configurator_controller.js',
'sale_product_configurator/static/src/js/product_configurator_view.js',
'sale_product_configurator/static/src/js/product_configurator_widget.js',
'sale_product_configurator/static/src/js/product_configurator_modal.js',
],
@@ -75,7 +75,8 @@ class ProductConfiguratorController(http.Controller):
'variant_values': variant_values,
'pricelist': pricelist,
'handle_stock': handle_stock,
'already_configured': kw.get("already_configured", False)
'already_configured': kw.get("already_configured", False),
'mode': kw.get('mode', 'add')
})
def _get_pricelist(self, pricelist_id, pricelist_fallback=False):
@@ -1,2 +0,0 @@
"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
"access_sale_product_configurator","access.sale.product.configurator","model_sale_product_configurator","sales_team.group_sale_salesman",1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_sale_product_configurator access.sale.product.configurator model_sale_product_configurator sales_team.group_sale_salesman 1 1 1 0
@@ -1,330 +0,0 @@
odoo.define('sale_product_configurator.ProductConfiguratorFormController', function (require) {
"use strict";
var core = require('web.core');
var _t = core._t;
var FormController = require('web.FormController');
var OptionalProductsModal = require('sale_product_configurator.OptionalProductsModal');
var ProductConfiguratorFormController = FormController.extend({
custom_events: _.extend({}, FormController.prototype.custom_events, {
field_changed: '_onFieldChanged',
handle_add: '_handleAdd'
}),
/**
* @override
*/
start: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
self.$el.addClass('o_product_configurator');
});
},
/**
* We need to first load the template of the selected product and then render the content
* to avoid a flicker when the modal is opened.
*
* @override
*/
willStart: function () {
var def = this._super.apply(this, arguments);
if (this.initialState.data.product_template_id) {
return this._configureProduct(
this.initialState.data.product_template_id.data.id
).then(function () {
return def;
});
}
return def;
},
/**
* Showing this window is useless for configuratorMode 'options' as this form view
* is used as a bridge between SO lines and optional products.
*
* Placed here because it's the only method that is called after the modal is rendered.
*
* @override
*/
renderButtons: function () {
this._super.apply(this, arguments);
if (this.renderer.state.context.configuratorMode === 'options') {
this.$el.closest('.modal').addClass('d-none');
}
const renderSecondOnly = this.renderer.state.context.configuratorMode !== 'edit';
this.$el.closest('.o_dialog_container').toggleClass('d-none', renderSecondOnly);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* We need to override the default click behavior for our "Add" button
* because there is a possibility that this product has optional products.
* If so, we need to display an extra modal to choose the options.
*
* @override
*/
_onButtonClicked: function (event) {
if (event.stopPropagation) {
event.stopPropagation();
}
var attrs = event.data.attrs;
if (attrs.special === 'cancel') {
this._super.apply(this, arguments);
} else {
if (!this.$el
.parents('.modal')
.find('.o_sale_product_configurator_add')
.hasClass('disabled')) {
this._handleAdd();
}
}
},
/**
* This is overridden to allow catching the "select" event on our product template select field.
*
* @override
* @private
*/
_onFieldChanged: function (event) {
this._super.apply(this, arguments);
var self = this;
var productId = event.data.changes.product_template_id.id;
// check to prevent traceback when emptying the field
if (!productId) {
return;
}
this._configureProduct(event.data.changes.product_template_id.id)
.then(function () {
self.renderer.renderConfigurator(self.renderer.configuratorHtml);
});
},
/**
* Renders the "variants" part of the wizard
*
* @param {integer} productTemplateId
*/
_configureProduct: function (productTemplateId) {
var self = this;
var initialProduct = this.initialState.data.product_template_id;
var changed = initialProduct && initialProduct.data.id !== productTemplateId;
var data = this.renderer.state.data;
var quantity = initialProduct.context && initialProduct.context.default_quantity ? initialProduct.context.default_quantity : data.quantity;
return this._rpc({
route: '/sale_product_configurator/configure',
params: {
product_template_id: productTemplateId,
pricelist_id: this.renderer.pricelistId,
add_qty: quantity,
product_template_attribute_value_ids: changed ? [] : this._getAttributeValueIds(
data.product_template_attribute_value_ids
),
product_no_variant_attribute_value_ids: changed ? [] : this._getAttributeValueIds(
data.product_no_variant_attribute_value_ids
),
context: this.getSession().user_context,
}
}).then(function (configurator) {
self.renderer.configuratorHtml = configurator;
});
},
/**
* When the user adds a product that has optional products, we need to display
* a window to allow the user to choose these extra options.
*
* This will also create the product if it's in "dynamic" mode
* (see product_attribute.create_variant)
*
* If "self.renderer.state.context.configuratorMode" is 'edit', this will only send
* the main product with its changes.
*
* As opposed to the 'add' mode that will add the main product AND all the configured optional products.
*
* A third mode, 'options', is available for products that don't have a configuration but have
* optional products to select. This will bypass the configuration step and open the
* options modal directly.
*
* @private
*/
_handleAdd: function () {
var self = this;
var $modal = this.$el;
var productSelector = [
'input[type="hidden"][name="product_id"]',
'input[type="radio"][name="product_id"]:checked'
];
var productId = parseInt($modal.find(productSelector.join(', ')).first().val(), 10);
var productTemplateId = $modal.find('.product_template_id').val();
this.renderer.selectOrCreateProduct(
$modal,
productId,
productTemplateId,
false
).then(function (productId) {
$modal.find(productSelector.join(', ')).val(productId);
var variantValues = self
.renderer
.getSelectedVariantValues($modal.find('.js_product'));
var productCustomVariantValues = self
.renderer
.getCustomVariantValues($modal.find('.js_product'));
var noVariantAttributeValues = self
.renderer
.getNoVariantAttributeValues($modal.find('.js_product'));
self.rootProduct = {
product_id: productId,
product_template_id: parseInt(productTemplateId),
quantity: parseFloat($modal.find('input[name="add_qty"]').val() || 1),
variant_values: variantValues,
product_custom_attribute_values: productCustomVariantValues,
no_variant_attribute_values: noVariantAttributeValues
};
if (self.renderer.state.context.configuratorMode === 'edit') {
// edit mode only takes care of main product
self._onAddRootProductOnly();
return;
}
self.optionalProductsModal = new OptionalProductsModal($('body'), {
rootProduct: self.rootProduct,
pricelistId: self.renderer.pricelistId,
okButtonText: _t('Confirm'),
cancelButtonText: _t('Back'),
title: _t('Configure'),
context: self.initialState.context,
previousModalHeight: self.$el.closest('.modal-content').height()
}).open();
self.optionalProductsModal.on('options_empty', null,
// no optional products found for this product, only add the root product
self._onAddRootProductOnly.bind(self));
self.optionalProductsModal.on('update_quantity', null,
self._onOptionsUpdateQuantity.bind(self));
self.optionalProductsModal.on('confirm', null,
self._onModalConfirm.bind(self));
self.optionalProductsModal.on('closed', null,
self._onModalClose.bind(self));
});
},
/**
* Add root product only and forget optional products.
* Used when product has no optional products and in 'edit' mode.
*
* @private
*/
_onAddRootProductOnly: function () {
this._addProducts([this.rootProduct]);
},
/**
* Add all selected products
*
* @private
*/
_onModalConfirm: function () {
this._wasConfirmed = true;
this.optionalProductsModal.getAndCreateSelectedProducts().then((products) => {
this._addProducts(products);
});
},
/**
* When the optional products modal is closed (and not confirmed) on 'options' mode,
* this window should also be closed immediately.
*
* @private
*/
_onModalClose: function () {
if (['options', 'add'].includes(this.renderer.state.context.configuratorMode)
&& this._wasConfirmed !== true) {
this.do_action({type: 'ir.actions.act_window_close'});
}
},
/**
* Remove "d-none" to allow other modals to display
*
* @override
*/
destroy: function () {
$('.o_dialog_container').removeClass('d-none');
this._super.apply(this, arguments);
},
/**
* Update product configurator form
* when quantity is updated in the optional products window
*
* @private
* @param {integer} quantity
*/
_onOptionsUpdateQuantity: function (quantity) {
this.$el
.find('input[name="add_qty"]')
.val(quantity)
.trigger('change');
},
/**
* This triggers the close action for the window and
* adds the product as the "infos" parameter.
* It will allow the caller (typically the product_configurator widget) of this window
* to handle the added products.
*
* @private
* @param {Array} products the list of added products
* {integer} products.product_id: the id of the product
* {integer} products.quantity: the added quantity for this product
* {Array} products.product_custom_attribute_values:
* see variant_mixin.getCustomVariantValues
* {Array} products.no_variant_attribute_values:
* see variant_mixin.getNoVariantAttributeValues
*/
_addProducts: function (products) {
this.do_action({type: 'ir.actions.act_window_close', infos: {
mainProduct: products[0],
options: products.slice(1)
}});
},
/**
* Extracts the ids from the passed attributeValueIds and returns them
* as a plain array.
*
* @param {Array} attributeValueIds
*/
_getAttributeValueIds: function (attributeValueIds) {
if (!attributeValueIds || attributeValueIds.length === 0) {
return false;
}
var result = [];
_.each(attributeValueIds.data, function (attributeValue) {
result.push(attributeValue.data.id);
});
return result;
}
});
return ProductConfiguratorFormController;
});
@@ -1,13 +1,12 @@
odoo.define('sale_product_configurator.OptionalProductsModal', function (require) {
"use strict";
/** @odoo-module */
var ajax = require('web.ajax');
var Dialog = require('web.Dialog');
const OwlDialog = require('web.OwlDialog');
var ServicesMixin = require('web.ServicesMixin');
var VariantMixin = require('sale.VariantMixin');
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';
var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
export const OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
events: _.extend({}, Dialog.prototype.events, VariantMixin.events, {
'click a.js_add, a.js_remove': '_onAddOrRemoveOption',
'click button.js_add_cart_json': 'onClickAddCartJSON',
@@ -43,7 +42,8 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
buttons: [{
text: params.okButtonText,
click: this._onConfirmButtonClick,
classes: 'btn-primary'
// 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
@@ -58,6 +58,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
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';
@@ -75,6 +76,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
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,
pricelist_id: self.pricelistId || false,
@@ -236,12 +238,11 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
$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}']`);
.closest(`[data-value_id='${this.custom_product_template_attribute_value_id.res_id}']`);
$customInput.attr('previous_custom_value', this.custom_value);
VariantMixin.handleCustomValues($customInput);
}
@@ -520,7 +521,3 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, {
return el.dataset.uniqueId;
},
});
return OptionalProductsModal;
});
@@ -1,136 +0,0 @@
odoo.define('sale_product_configurator.ProductConfiguratorFormRenderer', function (require) {
"use strict";
var FormRenderer = require('web.FormRenderer');
var VariantMixin = require('sale.VariantMixin');
var ProductConfiguratorFormRenderer = FormRenderer.extend(VariantMixin, {
events: _.extend({}, FormRenderer.prototype.events, VariantMixin.events, {
'click button.js_add_cart_json': 'onClickAddCartJSON',
}),
/**
* @override
*/
init: function () {
this._super.apply(this, arguments);
this.pricelistId = this.state.context.default_pricelist_id || 0;
},
/**
* @override
*/
start: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
self.$el.append($('<div>', {class: 'configurator_container'}));
self.renderConfigurator(self.configuratorHtml);
self._checkMode();
});
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Renders the product configurator within the form
*
* Will also:
*
* - add events handling for variant changes
* - trigger variant change to compute the price and other
* variant specific changes
*
* @param {string} configuratorHtml the evaluated template of
* the product configurator
*/
renderConfigurator: function (configuratorHtml) {
var $configuratorContainer = this.$('.configurator_container');
$configuratorContainer.empty();
var $configuratorHtml = $(configuratorHtml);
$configuratorHtml.appendTo($configuratorContainer);
this.triggerVariantChange($configuratorContainer);
this._applyCustomValues();
if (this.state.context.configuratorMode !== 'options') {
this.trigger_up('handle_add');
}
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* If the configuratorMode in the given context is 'edit', we need to
* hide the regular 'Add' button to replace it with an 'EDIT' button.
*
* If the configuratorMode is set to 'options', we will directly open the
* options modal.
*
* @private
*/
_checkMode: function () {
if (this.state.context.configuratorMode === 'edit') {
this.$('.o_sale_product_configurator_add').hide();
this.$('.o_sale_product_configurator_edit').css('display', 'inline-block');
} else if (this.state.context.configuratorMode === 'options') {
this.trigger_up('handle_add');
}
},
/**
* Toggles the add button depending on the possibility of the current
* combination.
*
* @override
*/
_toggleDisable: function ($parent, isCombinationPossible) {
VariantMixin._toggleDisable.apply(this, arguments);
$parent.parents('.modal').find('.o_sale_product_configurator_add').toggleClass('disabled', !isCombinationPossible);
},
/**
* Will fill the custom values input based on the provided initial configuration.
*
* @private
*/
_applyCustomValues: function () {
var self = this;
var customValueIds = this.state.data.product_custom_attribute_value_ids;
if (customValueIds) {
_.each(customValueIds.data, function (customValue) {
if (customValue.data.custom_value) {
var attributeValueId = customValue.data.custom_product_template_attribute_value_id.data.id;
var $input = self._findRelatedAttributeValueInput(attributeValueId);
$input
.closest('li[data-attribute_id]')
.find('.variant_custom_value')
.val(customValue.data.custom_value);
}
});
}
},
/**
* Find the $.Element input/select related to that product.attribute.value
*
* @param {integer} attributeValueId
*
* @private
*/
_findRelatedAttributeValueInput: function (attributeValueId) {
var selectors = [
'ul.js_add_cart_variants input[data-value_id="' + attributeValueId + '"]',
'ul.js_add_cart_variants option[data-value_id="' + attributeValueId + '"]'
];
return this.$(selectors.join(', '));
}
});
return ProductConfiguratorFormRenderer;
});
@@ -1,20 +0,0 @@
odoo.define('sale_product_configurator.ProductConfiguratorFormView', function (require) {
"use strict";
var ProductConfiguratorFormController = require('sale_product_configurator.ProductConfiguratorFormController');
var ProductConfiguratorFormRenderer = require('sale_product_configurator.ProductConfiguratorFormRenderer');
var FormView = require('web.FormView');
var viewRegistry = require('web.view_registry');
var ProductConfiguratorFormView = FormView.extend({
config: _.extend({}, FormView.prototype.config, {
Controller: ProductConfiguratorFormController,
Renderer: ProductConfiguratorFormRenderer,
}),
});
viewRegistry.add('product_configurator_form', ProductConfiguratorFormView);
return ProductConfiguratorFormView;
});
@@ -1,5 +1,12 @@
odoo.define('sale_product_configurator.product_configurator', function (require) {
var ProductConfiguratorWidget = require('sale.product_configurator');
/** @odoo-module */
import ProductConfiguratorWidget from "sale.product_configurator";
import { OptionalProductsModal } from "@sale_product_configurator/js/product_configurator_modal";
import {
selectOrCreateProduct,
getSelectedVariantValues,
getNoVariantAttributeValues,
} from "sale.VariantMixin";
import { _t } from "web.core";
/**
* Extension of the ProductConfiguratorWidget to support product configuration.
@@ -16,7 +23,7 @@ ProductConfiguratorWidget.include({
* Override of sale.product_configurator Hook
*
* @override
*/
*/
_isConfigurableProduct: function () {
return this.recordData.is_configurable_product || this._super.apply(this, arguments);
},
@@ -43,20 +50,20 @@ ProductConfiguratorWidget.include({
},
/**
* This method is overridden to check if the product_template_id
* needs configuration or not:
*
* - The product_template has only one "product.product" and is not dynamic
* -> Set the product_id on the SO line
* -> If the product has optional products, open the configurator in 'options' mode
*
* - The product_template is configurable
* -> Open the product configurator wizard and initialize it with
* the provided product_template_id and its current attribute values
*
* @override
* @private
*/
* This method is overridden to check if the product_template_id
* needs configuration or not:
*
* - The product_template has only one "product.product" and is not dynamic
* -> Set the product_id on the SO line
* -> If the product has optional products, open the configurator in 'options' mode
*
* - The product_template is configurable
* -> Open the product configurator wizard and initialize it with
* the provided product_template_id and its current attribute values
*
* @override
* @private
*/
_onTemplateChange: function (productTemplateId, dataPointId) {
var self = this;
var ctx = {};
@@ -65,19 +72,19 @@ ProductConfiguratorWidget.include({
}
return this._rpc({
model: 'product.template',
method: 'get_single_product_variant',
model: "product.template",
method: "get_single_product_variant",
args: [productTemplateId],
context: ctx,
}).then(function (result) {
if (result.product_id && !result.has_optional_products) {
self.trigger_up('field_changed', {
self.trigger_up("field_changed", {
dataPointID: dataPointId,
changes: {
product_id: {id: result.product_id},
product_id: { id: result.product_id },
product_custom_attribute_value_ids: {
operation: 'DELETE_ALL'
}
operation: "DELETE_ALL",
},
},
});
} else {
@@ -96,16 +103,16 @@ ProductConfiguratorWidget.include({
var self = this;
this._super.apply(this, arguments);
var parentList = self.getParent();
var unselectRow = (parentList.unselectRow || function() {}).bind(parentList); // form view on mobile
var unselectRow = (parentList.unselectRow || function () {}).bind(parentList); // form view on mobile
if (self.optionalProducts && self.optionalProducts.length !== 0) {
self.trigger_up('add_record', {
self.trigger_up("add_record", {
context: self._productsToRecords(self.optionalProducts),
forceEditable: 'bottom',
forceEditable: "bottom",
allowWarning: true,
onSuccess: function () {
// Leave edit mode of one2many list.
unselectRow();
}
},
});
} else if (!self._isConfigurableLine() && self._isConfigurableProduct()) {
// Leave edit mode of current line if line was configured
@@ -115,11 +122,12 @@ ProductConfiguratorWidget.include({
},
_openConfigurator: function (result, productTemplateId, dataPointId) {
if (!result.mode || result.mode === 'configurator') {
this._openProductConfigurator({
configuratorMode: result && result.has_optional_products ? 'options' : 'add',
default_pricelist_id: this._getPricelistId(),
default_product_template_id: productTemplateId
if (!result.mode || result.mode === "configurator") {
this._openProductConfigurator(
{
configuratorMode: result && result.has_optional_products ? "options" : "add",
pricelist_id: this._getPricelistId(),
product_template_id: productTemplateId,
},
dataPointId
);
@@ -149,40 +157,108 @@ ProductConfiguratorWidget.include({
*
* @private
*/
_openProductConfigurator: function (data, dataPointId) {
async _openProductConfigurator(data, dataPointId) {
this.optionalProducts = undefined;
var self = this;
this.do_action('sale_product_configurator.sale_product_configurator_action', {
additional_context: data,
on_close: function (result) {
if (result && !result.special) {
self._addProducts(result, dataPointId);
} else {
if (self.restoreProductTemplateId) {
// if configurator opened in edit mode.
self.trigger_up('field_changed', {
dataPointID: dataPointId,
preventProductIdCheck: true,
changes: {
product_template_id: self.restoreProductTemplateId.data
}
});
} else {
// if configurator opened to create line:
// destroy line if configurator closed during configuration process.
self.trigger_up('field_changed', {
dataPointID: dataPointId,
changes: {
product_template_id: false,
product_id: false,
},
});
}
}
const optionalProductsModal = await this._openModal(data);
// no optional products found for this product, only add the root product
optionalProductsModal.on("options_empty", null, () =>
this._addProducts({ mainProduct: this.rootProduct }, dataPointId)
);
let confirmed = false;
optionalProductsModal.on("confirm", null, async () => {
confirmed = true;
const [
mainProduct,
...options
] = await optionalProductsModal.getAndCreateSelectedProducts();
this._addProducts({ mainProduct, options }, dataPointId);
});
optionalProductsModal.on("closed", null, () => {
if (confirmed) {
return;
}
if (this.restoreProductTemplateId) {
// if configurator opened in edit mode.
this.trigger_up("field_changed", {
dataPointID: dataPointId,
preventProductIdCheck: true,
changes: {
product_template_id: this.restoreProductTemplateId.data,
},
});
} else {
// if configurator opened to create line:
// destroy line if configurator closed during configuration process.
this.trigger_up("field_changed", {
dataPointID: dataPointId,
changes: {
product_template_id: false,
product_id: false,
},
});
}
});
},
async _openModal(data) {
const {
product_template_id,
product_template_attribute_value_ids,
product_no_variant_attribute_value_ids,
pricelist_id,
product_custom_attribute_value_ids,
quantity,
configuratorMode,
} = data;
// FIXME replace this with an RPC instead of rendering a template and querying it for data
const $modal = $(
await this._rpc({
route: "/sale_product_configurator/configure",
params: {
product_template_id,
pricelist_id,
add_qty: quantity,
product_template_attribute_value_ids,
product_no_variant_attribute_value_ids,
context: this.getSession().user_context,
},
})
);
const productSelector = `input[type="hidden"][name="product_id"], input[type="radio"][name="product_id"]:checked`;
const productTemplateId = $modal.find(".product_template_id").val();
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);
this.rootProduct = {
product_id: productId,
product_template_id: parseInt(productTemplateId),
quantity: parseFloat($modal.find('input[name="add_qty"]').val() || 1),
variant_values: variantValues,
product_custom_attribute_values: product_custom_attribute_value_ids,
no_variant_attribute_values: noVariantAttributeValues,
};
return new OptionalProductsModal(null, {
rootProduct: this.rootProduct,
pricelistId: pricelist_id,
okButtonText: _t("Confirm"),
cancelButtonText: _t("Back"),
title: _t("Configure"),
context:
(this.record && this.recordParams && this.record.getContext(this.recordParams)) ||
{},
previousModalHeight: this.$el.closest(".modal-content").height(),
mode: configuratorMode,
}).open();
},
/**
* Opens the product configurator in "edit" mode.
* (see '_openProductConfigurator' for more info on the "edit" mode).
@@ -200,25 +276,30 @@ ProductConfiguratorWidget.include({
}
this.restoreProductTemplateId = this.recordData.product_template_id;
// If line has been set up through the product_configurator:
this._openProductConfigurator({
configuratorMode: 'edit',
default_product_template_id: this.recordData.product_template_id.data.id,
default_pricelist_id: this._getPricelistId(),
default_product_template_attribute_value_ids: this._convertFromMany2Many(
this._openProductConfigurator(
{
configuratorMode: "edit",
product_template_id: this.recordData.product_template_id.data.id,
pricelist_id: this._getPricelistId(),
product_template_attribute_value_ids: this._toIdArray(
this.recordData.product_template_attribute_value_ids
),
default_product_no_variant_attribute_value_ids: this._convertFromMany2Many(
product_no_variant_attribute_value_ids: this._toIdArray(
this.recordData.product_no_variant_attribute_value_ids
),
default_product_custom_attribute_value_ids: this._convertFromOne2Many(
this.recordData.product_custom_attribute_value_ids
product_custom_attribute_value_ids: this.recordData.product_custom_attribute_value_ids.data.map(
(d) => d.data
),
default_quantity: this.recordData.product_uom_qty
quantity: this.recordData.product_uom_qty,
},
this.dataPointID
);
},
_toIdArray(recordData) {
return recordData ? [...recordData.res_ids] : null;
},
/**
* This will first modify the SO line to update all the information coming from
* the product configurator using the 'field_changed' event.
@@ -238,11 +319,11 @@ ProductConfiguratorWidget.include({
* @private
*/
_addProducts: function (result, dataPointId) {
this.trigger_up('field_changed', {
this.trigger_up("field_changed", {
dataPointID: dataPointId,
preventProductIdCheck: true,
optionalProducts: result.options,
changes: this._getMainProductChanges(result.mainProduct)
changes: this._getMainProductChanges(result.mainProduct),
});
},
@@ -264,13 +345,13 @@ ProductConfiguratorWidget.include({
*/
_getMainProductChanges: function (mainProduct) {
var result = {
product_id: {id: mainProduct.product_id},
product_template_id: {id: mainProduct.product_template_id},
product_uom_qty: mainProduct.quantity
product_id: { id: mainProduct.product_id },
product_template_id: { id: mainProduct.product_template_id },
product_uom_qty: mainProduct.quantity,
};
var customAttributeValues = mainProduct.product_custom_attribute_values;
var customValuesCommands = [{operation: 'DELETE_ALL'}];
var customValuesCommands = [{ operation: "DELETE_ALL" }];
if (customAttributeValues && customAttributeValues.length !== 0) {
_.each(customAttributeValues, function (customValue) {
// FIXME awa: This could be optimized by adding a "disableDefaultGet" to avoid
@@ -280,36 +361,39 @@ ProductConfiguratorWidget.include({
// those "default_" values and set them on the various fields (text,o2m,m2m,...).
// -> This is not considered as worth it right now.
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
}]
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
result["product_custom_attribute_value_ids"] = {
operation: "MULTI",
commands: customValuesCommands,
};
var noVariantAttributeValues = mainProduct.no_variant_attribute_values;
var noVariantCommands = [{operation: 'DELETE_ALL'}];
var noVariantCommands = [{ operation: "DELETE_ALL" }];
if (noVariantAttributeValues && noVariantAttributeValues.length !== 0) {
var resIds = _.map(noVariantAttributeValues, function (noVariantValue) {
return {id: parseInt(noVariantValue.value)};
return { id: parseInt(noVariantValue.value) };
});
noVariantCommands.push({
operation: 'ADD_M2M',
ids: resIds
operation: "ADD_M2M",
ids: resIds,
});
}
result['product_no_variant_attribute_value_ids'] = {
operation: 'MULTI',
commands: noVariantCommands
result["product_no_variant_attribute_value_ids"] = {
operation: "MULTI",
commands: noVariantCommands,
};
return result;
@@ -339,41 +423,43 @@ ProductConfiguratorWidget.include({
var record = {
default_product_id: product.product_id,
default_product_template_id: product.product_template_id,
default_product_uom_qty: product.quantity
default_product_uom_qty: product.quantity,
};
if (product.no_variant_attribute_values) {
var defaultProductNoVariantAttributeValues = [];
_.each(product.no_variant_attribute_values, function (attributeValue) {
defaultProductNoVariantAttributeValues.push(
[4, parseInt(attributeValue.value)]
);
defaultProductNoVariantAttributeValues.push([
4,
parseInt(attributeValue.value),
]);
});
record['default_product_no_variant_attribute_value_ids']
= defaultProductNoVariantAttributeValues;
record[
"default_product_no_variant_attribute_value_ids"
] = defaultProductNoVariantAttributeValues;
}
if (product.product_custom_attribute_values) {
var defaultCustomAttributeValues = [];
_.each(product.product_custom_attribute_values, function (attributeValue) {
defaultCustomAttributeValues.push(
[0, 0, {
custom_product_template_attribute_value_id: attributeValue.custom_product_template_attribute_value_id,
custom_value: attributeValue.custom_value
}]
);
defaultCustomAttributeValues.push([
0,
0,
{
custom_product_template_attribute_value_id:
attributeValue.custom_product_template_attribute_value_id,
custom_value: attributeValue.custom_value,
},
]);
});
record['default_product_custom_attribute_value_ids']
= defaultCustomAttributeValues;
record["default_product_custom_attribute_value_ids"] = defaultCustomAttributeValues;
}
records.push(record);
});
return records;
}
},
});
return ProductConfiguratorWidget;
});
export { ProductConfiguratorWidget };
@@ -1,9 +1,8 @@
odoo.define('sale.product.configurator.tests', function (require) {
"use strict";
const FormView = require('web.FormView');
const ProductConfiguratorFormView = require('sale_product_configurator.ProductConfiguratorFormView');
const testUtils = require('web.test_utils');
/** @odoo-module */
import FormView from 'web.FormView';
import testUtils from 'web.test_utils';
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { ProductConfiguratorWidget } from "@sale_product_configurator/js/product_configurator_widget";
const createView = testUtils.createView;
const getArch = function (){
@@ -175,8 +174,11 @@ QUnit.module('Product Configurator', {
});
QUnit.test('Select a configurable product template and verify that the product configurator is opened', async function (assert) {
assert.expect(2);
patchWithCleanup(ProductConfiguratorWidget.prototype, {
_openProductConfigurator() {
assert.step("open configurator");
}
});
const form = await createView({
View: FormView,
model: 'sale_order',
@@ -189,17 +191,12 @@ QUnit.module('Product Configurator', {
}
return this._super.apply(this, arguments);
},
intercepts: {
do_action: function (ev) {
if (ev.data.action === 'sale_product_configurator.sale_product_configurator_action') {
assert.ok(true);
}
},
}
});
await testUtils.dom.click(form.$("a:contains('Add a product')"));
assert.verifySteps([]);
await testUtils.fields.many2one.searchAndClickItem("product_template_id", {item: 'Customizable Desk'});
assert.verifySteps(["open configurator"]);
form.destroy();
});
@@ -226,39 +223,6 @@ QUnit.module('Product Configurator', {
form.destroy();
});
QUnit.test('Select a product in the list and check for template loading', async function (assert) {
assert.expect(1);
const product_configurator_form = await createView({
View: ProductConfiguratorFormView,
model: 'sale_product_configurator',
data: this.data,
arch:
'<form js_class="product_configurator_form">' +
'<group>' +
'<field name="product_template_id" class="oe_product_configurator_product_template_id" />' +
'<field name="product_template_attribute_value_ids" invisible="1" />' +
'<field name="product_no_variant_attribute_value_ids" invisible="1" />' +
'<field name="product_custom_attribute_value_ids" invisible="1" />' +
'</group>' +
'<footer>' +
'<button string="Add" class="btn-primary o_sale_product_configurator_add disabled"/>' +
'<button string="Cancel" class="btn-secondary" special="cancel"/>' +
'</footer>' +
'</form>',
mockRPC: function (route) {
if (route === '/sale_product_configurator/configure') {
assert.ok(true);
return Promise.resolve('<div>plop</div>');
}
return this._super.apply(this, arguments);
}
});
await testUtils.dom.click(product_configurator_form.$('.o_input'));
await testUtils.dom.click($("ul.ui-autocomplete li a:contains('Customizable Desk')").mouseenter());
product_configurator_form.destroy();
});
QUnit.test('drag and drop rows containing product_configurator many2one', async function (assert) {
assert.expect(4);
@@ -305,5 +269,3 @@ QUnit.module('Product Configurator', {
form.destroy();
});
});
});
@@ -147,9 +147,11 @@
}'/>
</td>
</tr>
<tr t-if="product.optional_product_ids" class="o_select_options"><td colspan="4"><h4>Available Options:</h4></td></tr>
<t t-call="sale_product_configurator.optional_product_items">
<t t-set="parent_combination" t-value="combination"/>
<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-set="parent_combination" t-value="combination"/>
</t>
</t>
</tbody>
</table>
@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import sale_product_configurator
@@ -1,21 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class SaleProductConfigurator(models.TransientModel):
_name = 'sale.product.configurator'
_description = 'Sale Product Configurator'
product_template_id = fields.Many2one(
'product.template', string="Product",
required=True, domain=[('sale_ok', '=', True), ('attribute_line_ids.value_ids', '!=', False)])
quantity = fields.Integer('Quantity')
pricelist_id = fields.Many2one('product.pricelist', 'Pricelist', readonly=True)
product_template_attribute_value_ids = fields.Many2many(
'product.template.attribute.value', 'product_configurator_template_attribute_value_rel', string='Attribute Values', readonly=True)
product_custom_attribute_value_ids = fields.Many2many(
'product.attribute.custom.value', 'product_configurator_custom_attribute_value_rel', string="Custom Values")
product_no_variant_attribute_value_ids = fields.Many2many(
'product.template.attribute.value', 'product_configurator_no_variant_attribute_value_rel', string="Extra Values")
@@ -1,40 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="sale_product_configurator_view_form" model="ir.ui.view">
<field name="name">sale_product_configurator.product.configurator.view.form</field>
<field name="model">sale.product.configurator</field>
<field name="arch" type="xml">
<form js_class="product_configurator_form">
<group>
<field name="product_template_id" class="oe_product_configurator_product_template_id" options="{'no_open': True}" readonly="1"/>
<field name="product_template_attribute_value_ids" invisible="1">
<tree limit="10000"/>
</field>
<field name="product_custom_attribute_value_ids" invisible="1" widget="one2many" >
<tree limit="10000">
<field name="custom_product_template_attribute_value_id"/>
<field name="custom_value"/>
</tree>
</field>
<field name="product_no_variant_attribute_value_ids" invisible="1">
<tree limit="10000"/>
</field>
<field name="quantity" invisible="1" />
</group>
<footer>
<button string="Add" class="btn-primary o_sale_product_configurator_add" special="add" data-hotkey="g"/>
<button string="Save" class="btn-primary o_sale_product_configurator_edit" style="display: none;" special="save" data-hotkey="v"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
</footer>
</form>
</field>
</record>
<record id="sale_product_configurator_action" model="ir.actions.act_window">
<field name="name">Configure a product</field>
<field name="res_model">sale.product.configurator</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="view_id" ref="sale_product_configurator_view_form"/>
</record>
</odoo>
@@ -1,5 +1,5 @@
odoo.define('sale_product_matrix.product_configurator', function (require) {
var ProductConfiguratorWidget = require('sale_product_configurator.product_configurator');
/** @odoo-module */
import { ProductConfiguratorWidget } from "@sale_product_configurator/js/product_configurator_widget";
/**
* Extension of the ProductConfiguratorWidget to support product configuration
@@ -10,15 +10,14 @@ var ProductConfiguratorWidget = require('sale_product_configurator.product_confi
*
*/
ProductConfiguratorWidget.include({
/**
* @override
*/
_openConfigurator: function (result, productTemplateId, dataPointId) {
_openConfigurator: function (result, productTemplateId, dataPointId) {
var self = this;
var mode = result.mode;
this._super.apply(this, arguments).then(function (configuratorOpened) {
if (!configuratorOpened && mode === 'matrix') {
if (!configuratorOpened && mode === "matrix") {
self._openGridConfigurator(productTemplateId, dataPointId);
return Promise.resolve(true);
}
@@ -28,9 +27,9 @@ ProductConfiguratorWidget.include({
_openGridConfigurator: function (productTemplateId, dataPointId, edit) {
var attribs = edit ? this._getPTAVS() : [];
this.trigger_up('open_matrix', {
this.trigger_up("open_matrix", {
product_template_id: productTemplateId,
model: 'sale.order',
model: "sale.order",
dataPointId: dataPointId,
edit: edit,
editedCellAttributes: attribs,
@@ -38,42 +37,26 @@ ProductConfiguratorWidget.include({
},
_onEditProductConfiguration: function () {
const { _super } = this;
if (!this.recordData.is_configurable_product) {
// if line should be edited by another configurator
// or simply inline.
this._super.apply(this, arguments);
_super.apply(this, arguments);
return;
}
var self = this;
var productTemplateId = this.recordData.product_template_id.data.id;
this._rpc({
model: 'product.template',
method: 'read',
args: [productTemplateId, ['product_add_mode']],
model: "product.template",
method: "read",
args: [productTemplateId, ["product_add_mode"]],
}).then(function (result) {
if (result && result[0].product_add_mode === 'matrix') {
if (result && result[0].product_add_mode === "matrix") {
self._openGridConfigurator(productTemplateId, self.dataPointID, true);
} else {
self.restoreProductTemplateId = self.recordData.product_template_id;
// Call super only if product_add_mode different than matrix
// to avoid product configurator opening (which is the default case).
self._openProductConfigurator({
configuratorMode: 'edit',
default_product_template_id: self.recordData.product_template_id.data.id,
default_pricelist_id: self._getPricelistId(),
default_product_template_attribute_value_ids: self._convertFromMany2Many(
self.recordData.product_template_attribute_value_ids
),
default_product_no_variant_attribute_value_ids: self._convertFromMany2Many(
self.recordData.product_no_variant_attribute_value_ids
),
default_product_custom_attribute_value_ids: self._convertFromOne2Many(
self.recordData.product_custom_attribute_value_ids
),
default_quantity: self.recordData.product_uom_qty
},
self.dataPointID
);
return _super.apply(self, arguments);
}
});
},
@@ -81,7 +64,7 @@ ProductConfiguratorWidget.include({
/**
* Returns the list of attribute ids (product.template.attribute.value)
* from the current SOLine.
*/
*/
_getPTAVS: function () {
var PTAVSIDS = [];
_.each(this.recordData.product_no_variant_attribute_value_ids.res_ids, function (id) {
@@ -90,9 +73,8 @@ ProductConfiguratorWidget.include({
_.each(this.recordData.product_template_attribute_value_ids.res_ids, function (id) {
PTAVSIDS.push(id);
});
return PTAVSIDS.sort(function (a, b) {return a - b;});
}
});
return PTAVSIDS.sort(function (a, b) {
return a - b;
});
},
});
@@ -45,13 +45,13 @@ tour.register('sale_product_configurator_edition_tour', {
}, {
trigger: '.o_edit_product_configuration',
}, {
trigger: '.configurator_container li.js_attribute_value:has(span:contains("Aluminium")) input:checked',
trigger: '.main_product li.js_attribute_value:has(span:contains("Aluminium")) input:checked',
run: function (){} // check updated legs
}, {
trigger: 'span.oe_currency_value:contains("800")',
run: function (){} // check updated price
}, {
trigger: '.configurator_container span:contains("Steel")',
trigger: '.main_product span:contains("Steel")',
run: function () {
$('input.product_id').change(function () {
if ($('.o_sale_product_configurator_edit').attr('request_count')) {
@@ -63,20 +63,20 @@ tour.register('sale_product_configurator_edition_tour', {
});
}
}, {
trigger: '.configurator_container span:contains("Custom")',
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)
$('.configurator_container span:contains("Custom")').click();
$('.main_product span:contains("Custom")').click();
}
}, {
trigger: '.configurator_container .variant_custom_value',
trigger: '.main_product .variant_custom_value',
run: 'text nice custom value'
}, {
trigger: 'input[data-value_name="Black"]',
}, {
trigger: '.o_sale_product_configurator_edit[request_count="2"]',
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_edit',
@@ -93,7 +93,7 @@ tour.register('sale_product_configurator_edition_tour', {
}, {
trigger: '.o_edit_product_configuration',
}, {
trigger: '.configurator_container .variant_custom_value',
trigger: '.main_product .variant_custom_value',
run: 'text another nice custom value'
}, {
trigger: '.o_sale_product_configurator_edit',
@@ -106,25 +106,25 @@ tour.register('sale_product_configurator_edition_tour', {
}, {
trigger: '.o_edit_product_configuration',
}, {
trigger: '.configurator_container span:contains("Steel")',
trigger: '.main_product span:contains("Steel")',
run: function () {
$('input.product_id').change(function () {
$('.o_sale_product_configurator_edit').attr('request_count', 1);
});
}
}, {
trigger: '.configurator_container span:contains("Steel")',
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)
$('.configurator_container span:contains("Steel")').click();
$('.main_product span:contains("Steel")').click();
}
}, {
trigger: '.o_sale_product_configurator_edit[request_count="1"]',
run: function (){} // used to sync with "get_combination_info" completion
}, {
trigger: '.configurator_container button.js_add_cart_json:has(.fa-plus)',
trigger: '.main_product button.js_add_cart_json:has(.fa-plus)',
}, {
trigger: '.o_sale_product_configurator_edit',
}, {
@@ -45,20 +45,20 @@ tour.register('sale_product_configurator_single_custom_attribute_tour', {
}, {
trigger: '.o_edit_product_configuration',
}, {
trigger: '.configurator_container .variant_custom_value',
trigger: '.main_product .variant_custom_value',
run: function () {
// check custom value initialized
if ($('.configurator_container .variant_custom_value').val() === "great single custom value") {
$('.configurator_container').addClass('tour_success_2');
if ($('.main_product .variant_custom_value').val() === "great single custom value") {
$('.main_product').addClass('tour_success_2');
}
}
}, {
trigger: '.configurator_container.tour_success_2',
trigger: '.main_product.tour_success_2',
run: function () {
//check
}
}, {
trigger: '.configurator_container',
trigger: '.main_product',
run: function () {
window.location.href = window.location.origin + '/web';
}
@@ -1,7 +1,7 @@
odoo.define('website_sale_product_configurator.OptionalProductsModal', function (require) {
"use strict";
var OptionalProductsModal = require('sale_product_configurator.OptionalProductsModal');
const { OptionalProductsModal } = require('@sale_product_configurator/js/product_configurator_modal');
OptionalProductsModal.include({
/**
@@ -4,7 +4,7 @@ odoo.define('website_sale_options.website_sale', function (require) {
var ajax = require('web.ajax');
var core = require('web.core');
var publicWidget = require('web.public.widget');
var OptionalProductsModal = require('sale_product_configurator.OptionalProductsModal');
var { OptionalProductsModal } = require('@sale_product_configurator/js/product_configurator_modal');
require('website_sale.website_sale');
var _t = core._t;